From 44cf8d98551b11f861db04f3d02065c6bb20378a Mon Sep 17 00:00:00 2001 From: Alexander Birkner Date: Wed, 14 Jan 2026 08:56:56 +0100 Subject: [PATCH 1/3] Prepare initial release and documentation --- .gitignore | 27 +---- LICENSE => LICENSE.md | 0 README.md | 108 +++++++++++++++++- pom.xml | 31 +++++ .../com/gportal/source/query/Message.java | 21 ++++ .../gportal/source/query/MessageCodec.java | 40 +++++++ .../com/gportal/source/query/PlayerInfo.java | 25 ++++ .../java/com/gportal/source/query/Query.java | 6 + .../com/gportal/source/query/QueryClient.java | 97 ++++++++++++++++ .../com/gportal/source/query/QueryServer.java | 66 +++++++++++ .../java/com/gportal/source/query/Reply.java | 5 + .../com/gportal/source/query/ServerInfo.java | 70 ++++++++++++ .../source/query/messages/ChallengeReply.java | 21 ++++ .../source/query/messages/InfoQuery.java | 28 +++++ .../source/query/messages/InfoReply.java | 22 ++++ .../source/query/messages/PlayerQuery.java | 24 ++++ .../source/query/messages/PlayerReply.java | 31 +++++ .../source/query/messages/RulesQuery.java | 24 ++++ .../source/query/messages/RulesReply.java | 34 ++++++ src/test/java/TestQueryClient.java | 49 ++++++++ src/test/java/TestQueryServer.java | 49 ++++++++ 21 files changed, 754 insertions(+), 24 deletions(-) rename LICENSE => LICENSE.md (100%) create mode 100644 pom.xml create mode 100644 src/main/java/com/gportal/source/query/Message.java create mode 100644 src/main/java/com/gportal/source/query/MessageCodec.java create mode 100644 src/main/java/com/gportal/source/query/PlayerInfo.java create mode 100644 src/main/java/com/gportal/source/query/Query.java create mode 100644 src/main/java/com/gportal/source/query/QueryClient.java create mode 100644 src/main/java/com/gportal/source/query/QueryServer.java create mode 100644 src/main/java/com/gportal/source/query/Reply.java create mode 100644 src/main/java/com/gportal/source/query/ServerInfo.java create mode 100644 src/main/java/com/gportal/source/query/messages/ChallengeReply.java create mode 100644 src/main/java/com/gportal/source/query/messages/InfoQuery.java create mode 100644 src/main/java/com/gportal/source/query/messages/InfoReply.java create mode 100644 src/main/java/com/gportal/source/query/messages/PlayerQuery.java create mode 100644 src/main/java/com/gportal/source/query/messages/PlayerReply.java create mode 100644 src/main/java/com/gportal/source/query/messages/RulesQuery.java create mode 100644 src/main/java/com/gportal/source/query/messages/RulesReply.java create mode 100644 src/test/java/TestQueryClient.java create mode 100644 src/test/java/TestQueryServer.java diff --git a/.gitignore b/.gitignore index 524f096..6a2f002 100644 --- a/.gitignore +++ b/.gitignore @@ -1,24 +1,5 @@ -# Compiled class file -*.class +# IDE +/.idea/ -# Log file -*.log - -# BlueJ files -*.ctxt - -# Mobile Tools for Java (J2ME) -.mtj.tmp/ - -# Package Files # -*.jar -*.war -*.nar -*.ear -*.zip -*.tar.gz -*.rar - -# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml -hs_err_pid* -replay_pid* +# Build +/target/ \ No newline at end of file diff --git a/LICENSE b/LICENSE.md similarity index 100% rename from LICENSE rename to LICENSE.md diff --git a/README.md b/README.md index 0faeba2..47afab9 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,108 @@ # a2s-java -Valve Steam Query Protocol implementation for Java + +A Valve Steam Query Protocol (A2S) implementation for Java using Netty. + +This library allows you to query game servers that implement the Source Engine Query protocol, such as Counter-Strike, Team Fortress 2, Rust, ARK, and many others. + +> [!NOTE] +> This code is based on and used from [yeetus-desastroesus/A2S-Java](https://github.com/yeetus-desastroesus/A2S-Java). + +## Features + +- **A2S_INFO**: Get server information (name, map, player count, etc.). +- **A2S_PLAYER**: Get detailed list of players currently on the server. +- **A2S_RULES**: Get server rules/CVars. +- **Asynchronous**: Built on Netty for high-performance, non-blocking I/O. +- **Server Implementation**: Includes a basic A2S server implementation for testing or mocking. + +## Installation + +The library is published on the Maven registry. You can include it in your project using Maven or Gradle. + +### Maven + +Add the following dependency to your `pom.xml`: + +```xml + + com.gportal + a2s + 1.0.0-SNAPSHOT + +``` + +### Gradle + +Add the following to your `build.gradle`: + +```gradle +dependencies { + implementation 'com.gportal:a2s:' +} +``` + +## Usage + +### Querying a Server (Client) + +```java +import com.gportal.source.query.QueryClient; +import com.gportal.source.query.ServerInfo; +import com.gportal.source.query.PlayerInfo; +import java.net.InetSocketAddress; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +public class Example { + public static void main(String[] args) throws Exception { + QueryClient client = new QueryClient(); + InetSocketAddress address = new InetSocketAddress("127.0.0.1", 27015); + + // Query Server Info + CompletableFuture infoFuture = client.queryServer(address); + infoFuture.thenAccept(info -> System.out.println("Server Name: " + info.name())); + + // Query Players + CompletableFuture> playersFuture = client.queryPlayers(address); + playersFuture.thenAccept(players -> players.forEach(p -> System.out.println("Player: " + p.name()))); + + // Query Rules + CompletableFuture> rulesFuture = client.queryRules(address); + rulesFuture.thenAccept(rules -> System.out.println("Rules count: " + rules.size())); + + // Don't forget to shutdown when done + // client.shutdown(); + } +} +``` + +### Starting an A2S Server + +```java +import com.gportal.source.query.QueryServer; +import com.gportal.source.query.ServerInfo; +import com.gportal.source.query.PlayerInfo; + +public class ServerExample { + public static void main(String[] args) { + ServerInfo info = new ServerInfo( + null, (byte)17, "My Java Game Server", "de_dust2", "csgo", "Counter-Strike: Global Offensive", + (short)730, (byte)0, (byte)20, (byte)0, 'd', 'l', false, true, "1.0.0.0", + null, null, null, null, null, null + ); + + QueryServer server = new QueryServer(27015, info); + + // Add some players or rules + server.players.add(new PlayerInfo((byte)0, "Gordon Freeman", (short)100, 300.0f)); + server.rules.put("mp_timelimit", "30"); + + System.out.println("A2S Server started on port 27015"); + } +} +``` + +## License + +This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details. diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..ce2601f --- /dev/null +++ b/pom.xml @@ -0,0 +1,31 @@ + + + 4.0.0 + + com.gportal + a2s + ${revision} + + a2s-query + + + 1.0.0-SNAPSHOT + 25 + 25 + UTF-8 + + + + + io.netty + netty-all + 4.2.9.Final + + + org.junit.jupiter + junit-jupiter-api + 6.0.2 + test + + + diff --git a/src/main/java/com/gportal/source/query/Message.java b/src/main/java/com/gportal/source/query/Message.java new file mode 100644 index 0000000..42eb566 --- /dev/null +++ b/src/main/java/com/gportal/source/query/Message.java @@ -0,0 +1,21 @@ +package com.gportal.source.query; + +import java.net.InetSocketAddress; + +import io.netty.buffer.ByteBuf; + +public interface Message { + public InetSocketAddress remoteAddress(); + public Message write(ByteBuf buffer); + + public static String readString(ByteBuf buffer) { + String val = ""; + byte in; + while((in = buffer.readByte()) != 0) val += (char) in; + return val; + } + public static void writeString(ByteBuf buffer, String val) { + buffer.writeBytes(val.getBytes()); + buffer.writeByte(0); + } +} diff --git a/src/main/java/com/gportal/source/query/MessageCodec.java b/src/main/java/com/gportal/source/query/MessageCodec.java new file mode 100644 index 0000000..f86d6a8 --- /dev/null +++ b/src/main/java/com/gportal/source/query/MessageCodec.java @@ -0,0 +1,40 @@ +package com.gportal.source.query; + +import java.util.List; + +import com.gportal.source.query.messages.ChallengeReply; +import com.gportal.source.query.messages.InfoQuery; +import com.gportal.source.query.messages.InfoReply; +import com.gportal.source.query.messages.PlayerQuery; +import com.gportal.source.query.messages.PlayerReply; +import com.gportal.source.query.messages.RulesQuery; +import com.gportal.source.query.messages.RulesReply; + +import io.netty.buffer.ByteBuf; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.socket.DatagramPacket; +import io.netty.handler.codec.MessageToMessageCodec; + +public class MessageCodec extends MessageToMessageCodec { + protected void decode(ChannelHandlerContext ctx, DatagramPacket msg, List out) throws Exception { + int header = msg.content().readIntLE(); + if(header != -1) throw new UnsupportedOperationException("we dont support split packets yet"); + byte op = msg.content().readByte(); + switch(op) { + case ChallengeReply.OP: out.add(ChallengeReply.read(msg.sender(), msg.content())); break; + case InfoQuery.OP: out.add(InfoQuery.read(msg.sender(), msg.content())); break; + case InfoReply.OP: out.add(InfoReply.read(msg.sender(), msg.content())); break; + case PlayerQuery.OP: out.add(PlayerQuery.read(msg.sender(), msg.content())); break; + case PlayerReply.OP: out.add(PlayerReply.read(msg.sender(), msg.content())); break; + case RulesQuery.OP: out.add(RulesQuery.read(msg.sender(), msg.content())); break; + case RulesReply.OP: out.add(RulesReply.read(msg.sender(), msg.content())); break; + default: throw new UnsupportedOperationException("Unknown OP 0x" + String.format("%2x", op).replaceAll(" ", "0")); + } + } + protected void encode(ChannelHandlerContext ctx, Message msg, List out) throws Exception { + ByteBuf buffer = ctx.alloc().buffer(); + buffer.writeIntLE(-1); + msg.write(buffer); + out.add(new DatagramPacket(buffer, msg.remoteAddress())); + } +} diff --git a/src/main/java/com/gportal/source/query/PlayerInfo.java b/src/main/java/com/gportal/source/query/PlayerInfo.java new file mode 100644 index 0000000..9b3477f --- /dev/null +++ b/src/main/java/com/gportal/source/query/PlayerInfo.java @@ -0,0 +1,25 @@ +package com.gportal.source.query; + +import static com.gportal.source.query.Message.readString; +import static com.gportal.source.query.Message.writeString; + +import io.netty.buffer.ByteBuf; + +public record PlayerInfo(byte index, String name, int score, float duration) { + public static PlayerInfo read(ByteBuf buffer) { + byte index = buffer.readByte(); + String name = readString(buffer); + int score = buffer.readIntLE(); + float duration = buffer.readFloatLE(); + + return new PlayerInfo(index, name, score, duration); + } + public PlayerInfo write(ByteBuf buffer) { + buffer.writeByte(index()); + writeString(buffer, name()); + buffer.writeIntLE(score()); + buffer.writeFloatLE(duration()); + + return this; + } +} diff --git a/src/main/java/com/gportal/source/query/Query.java b/src/main/java/com/gportal/source/query/Query.java new file mode 100644 index 0000000..95e3fe0 --- /dev/null +++ b/src/main/java/com/gportal/source/query/Query.java @@ -0,0 +1,6 @@ +package com.gportal.source.query; + +public interface Query extends Message { + public Integer challenge(); + public Query withChallenge(int challenge); +} diff --git a/src/main/java/com/gportal/source/query/QueryClient.java b/src/main/java/com/gportal/source/query/QueryClient.java new file mode 100644 index 0000000..f49acbb --- /dev/null +++ b/src/main/java/com/gportal/source/query/QueryClient.java @@ -0,0 +1,97 @@ +package com.gportal.source.query; + +import java.net.InetSocketAddress; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import com.gportal.source.query.messages.ChallengeReply; +import com.gportal.source.query.messages.InfoQuery; +import com.gportal.source.query.messages.PlayerQuery; +import com.gportal.source.query.messages.RulesQuery; + +import io.netty.bootstrap.Bootstrap; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelInitializer; +import io.netty.channel.EventLoopGroup; +import io.netty.channel.SimpleChannelInboundHandler; +import io.netty.channel.nio.NioEventLoopGroup; +import io.netty.channel.socket.DatagramChannel; +import io.netty.channel.socket.nio.NioDatagramChannel; + +public class QueryClient { + private static final String SRC = "Source Engine Query"; + + private DatagramChannel channel; + private EventLoopGroup worker; + private Map>> requests = new HashMap>>(); + + public QueryClient() { + worker = new NioEventLoopGroup(); + Bootstrap bootstrap = new Bootstrap() + .group(worker) + .channel(NioDatagramChannel.class) + .handler(new ChannelInitializer() { + protected void initChannel(NioDatagramChannel ch) throws Exception { + ch.pipeline().addLast( + new MessageCodec(), + new SimpleChannelInboundHandler() { + protected void channelRead0(ChannelHandlerContext ctx, Reply msg) throws Exception { + Map.Entry> request = requests.get(msg.remoteAddress()); + if(request != null) { + @SuppressWarnings("unchecked") + CompletableFuture future = (CompletableFuture) request.getValue(); + future.complete(msg.payload()); + clearRequest(msg.remoteAddress()); + } + } + }, + new SimpleChannelInboundHandler() { + protected void channelRead0(ChannelHandlerContext ctx, ChallengeReply msg) throws Exception { + Map.Entry> request = requests.get(msg.remoteAddress()); + if(request != null) { + ctx.writeAndFlush(request.getKey().withChallenge(msg.payload())); + } + } + } + ); + } + }); + channel = (DatagramChannel) bootstrap.bind(0).syncUninterruptibly().channel(); + } + + private void writeRequest(Query request, CompletableFuture future) { + clearRequest(request.remoteAddress()); + requests.put(request.remoteAddress(), Map.entry(request, future)); + channel.pipeline().writeAndFlush(request); + } + + private void clearRequest(InetSocketAddress remoteAddress) { + if(requests.get(remoteAddress) != null) { + requests.get(remoteAddress).getValue().cancel(true); + requests.put(remoteAddress, null); + } + } + + public CompletableFuture queryServer(InetSocketAddress serverAddress) { + CompletableFuture future = new CompletableFuture(); + writeRequest(new InfoQuery(serverAddress, SRC, null), future); + return future; + } + public CompletableFuture> queryPlayers(InetSocketAddress serverAddress) { + CompletableFuture> future = new CompletableFuture>(); + writeRequest(new PlayerQuery(serverAddress, null), future); + return future; + } + public CompletableFuture> queryRules(InetSocketAddress serverAddress) { + CompletableFuture> future = new CompletableFuture>(); + writeRequest(new RulesQuery(serverAddress, null), future); + return future; + } + + public void shutdown() { + channel.close(); + worker.shutdownGracefully(); + } +} diff --git a/src/main/java/com/gportal/source/query/QueryServer.java b/src/main/java/com/gportal/source/query/QueryServer.java new file mode 100644 index 0000000..998030a --- /dev/null +++ b/src/main/java/com/gportal/source/query/QueryServer.java @@ -0,0 +1,66 @@ +package com.gportal.source.query; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import com.gportal.source.query.messages.InfoQuery; +import com.gportal.source.query.messages.InfoReply; +import com.gportal.source.query.messages.PlayerQuery; +import com.gportal.source.query.messages.PlayerReply; +import com.gportal.source.query.messages.RulesQuery; +import com.gportal.source.query.messages.RulesReply; + +import io.netty.bootstrap.Bootstrap; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelInitializer; +import io.netty.channel.EventLoopGroup; +import io.netty.channel.SimpleChannelInboundHandler; +import io.netty.channel.nio.NioEventLoopGroup; +import io.netty.channel.socket.DatagramChannel; +import io.netty.channel.socket.nio.NioDatagramChannel; + +public class QueryServer { + private DatagramChannel channel; + private EventLoopGroup worker; + public final ServerInfo info; + public final Map rules = new HashMap(); + public final List players = new ArrayList(); + + public QueryServer(int port, ServerInfo info) { + this.info = info; + worker = new NioEventLoopGroup(); + Bootstrap bootstrap = new Bootstrap() + .group(worker) + .channel(NioDatagramChannel.class) + .handler(new ChannelInitializer() { + protected void initChannel(NioDatagramChannel ch) throws Exception { + ch.pipeline().addLast( + new MessageCodec(), + new SimpleChannelInboundHandler() { + protected void channelRead0(ChannelHandlerContext ctx, InfoQuery msg) throws Exception { + ctx.writeAndFlush(new InfoReply(msg.remoteAddress(), QueryServer.this.info)); + } + }, + new SimpleChannelInboundHandler() { + protected void channelRead0(ChannelHandlerContext ctx, PlayerQuery msg) throws Exception { + ctx.writeAndFlush(new PlayerReply(msg.remoteAddress(), players)); + } + }, + new SimpleChannelInboundHandler() { + protected void channelRead0(ChannelHandlerContext ctx, RulesQuery msg) throws Exception { + ctx.writeAndFlush(new RulesReply(msg.remoteAddress(), rules)); + } + } + ); + } + }); + channel = (DatagramChannel) bootstrap.bind(port).syncUninterruptibly().channel(); + } + + public void shutdown() { + channel.close(); + worker.shutdownGracefully(); + } +} diff --git a/src/main/java/com/gportal/source/query/Reply.java b/src/main/java/com/gportal/source/query/Reply.java new file mode 100644 index 0000000..2fcb483 --- /dev/null +++ b/src/main/java/com/gportal/source/query/Reply.java @@ -0,0 +1,5 @@ +package com.gportal.source.query; + +public interface Reply extends Message { + public Object payload(); +} diff --git a/src/main/java/com/gportal/source/query/ServerInfo.java b/src/main/java/com/gportal/source/query/ServerInfo.java new file mode 100644 index 0000000..f784c4f --- /dev/null +++ b/src/main/java/com/gportal/source/query/ServerInfo.java @@ -0,0 +1,70 @@ +package com.gportal.source.query; + +import static com.gportal.source.query.Message.readString; +import static com.gportal.source.query.Message.writeString; + +import java.net.InetSocketAddress; + +import io.netty.buffer.ByteBuf; + +public record ServerInfo(InetSocketAddress queryAddress, byte protocol, String name, String map, String folder, String game, short appId, byte players, byte maxPlayers, byte bots, char serverType, char environment, boolean password, boolean vac, String version, Short port, Long steamId, Short tvPort, String tvName, String config, Long gameId) { + public InetSocketAddress gameAddress() { return new InetSocketAddress(queryAddress().getAddress(), port()); } + public static ServerInfo read(InetSocketAddress queryAddress, ByteBuf buffer) { + byte protocol = buffer.readByte(); + String name = readString(buffer); + String map = readString(buffer); + String folder = readString(buffer); + String game = readString(buffer); + short appId = buffer.readShortLE(); + byte players = buffer.readByte(); + byte maxPlayers = buffer.readByte(); + byte bots = buffer.readByte(); + char serverType = (char) buffer.readByte(); + char environment = (char) buffer.readByte(); + boolean password = buffer.readBoolean(); + boolean vac = buffer.readBoolean(); + String version = readString(buffer); + + byte extra = buffer.readableBytes()>0?buffer.readByte():0; + Short port = (extra&0x80)!=0?buffer.readShortLE():null; + Long steamId = (extra&0x10)!=0?buffer.readLongLE():null; + Short tvPort = (extra&0x40)!=0?buffer.readShortLE():null; + String tvName = (extra&0x40)!=0?readString(buffer):null; + String config = (extra&0x20)!=0?readString(buffer):null; + Long gameId = (extra&0x01)!=0?buffer.readLongLE():null; + + return new ServerInfo(queryAddress, protocol, name, map, folder, game, appId, players, maxPlayers, bots, serverType, environment, password, vac, version, port, steamId, tvPort, tvName, config, gameId); + } + public ServerInfo write(ByteBuf buffer) { + buffer.writeByte(protocol()); + writeString(buffer, name()); + writeString(buffer, map()); + writeString(buffer, folder()); + writeString(buffer, game()); + buffer.writeShortLE(appId()); + buffer.writeByte(players()); + buffer.writeByte(maxPlayers()); + buffer.writeByte(bots()); + buffer.writeByte(serverType()); + buffer.writeByte(environment()); + buffer.writeBoolean(password()); + buffer.writeBoolean(vac()); + writeString(buffer, version()); + + byte extra = 0; + if(port()!=null) extra|=0x80; + if(steamId()!=null) extra|=0x10; + if(tvPort()!=null&&tvName()!=null) extra|=0x40; + if(config()!=null) extra|=0x20; + if(gameId()!=null) extra|=0x01; + if(extra!=0) buffer.writeByte(extra); + if((extra&0x80)!=0) buffer.writeShortLE(port()); + if((extra&0x10)!=0) buffer.writeLongLE(steamId()); + if((extra&0x40)!=0) buffer.writeShortLE(tvPort()); + if((extra&0x40)!=0) writeString(buffer, tvName()); + if((extra&0x20)!=0) writeString(buffer, config()); + if((extra&0x01)!=0) buffer.writeLongLE(gameId()); + + return this; + } +} diff --git a/src/main/java/com/gportal/source/query/messages/ChallengeReply.java b/src/main/java/com/gportal/source/query/messages/ChallengeReply.java new file mode 100644 index 0000000..53354f8 --- /dev/null +++ b/src/main/java/com/gportal/source/query/messages/ChallengeReply.java @@ -0,0 +1,21 @@ +package com.gportal.source.query.messages; + +import java.net.InetSocketAddress; + +import com.gportal.source.query.Message; + +import io.netty.buffer.ByteBuf; + +public record ChallengeReply(InetSocketAddress remoteAddress, int payload) implements Message { + public static final byte OP = 0x41; + + public static ChallengeReply read(InetSocketAddress remoteAddress, ByteBuf buffer) { + int payload = buffer.readIntLE(); + return new ChallengeReply(remoteAddress, payload); + } + public ChallengeReply write(ByteBuf buffer) { + buffer.writeByte(OP); + buffer.writeIntLE(payload); + return this; + } +} diff --git a/src/main/java/com/gportal/source/query/messages/InfoQuery.java b/src/main/java/com/gportal/source/query/messages/InfoQuery.java new file mode 100644 index 0000000..2474aa3 --- /dev/null +++ b/src/main/java/com/gportal/source/query/messages/InfoQuery.java @@ -0,0 +1,28 @@ +package com.gportal.source.query.messages; + +import static com.gportal.source.query.Message.readString; +import static com.gportal.source.query.Message.writeString; + +import java.net.InetSocketAddress; + +import com.gportal.source.query.Query; + +import io.netty.buffer.ByteBuf; + +public record InfoQuery(InetSocketAddress remoteAddress, String payload, Integer challenge) implements Query { + public static final byte OP = 0x54; + + public InfoQuery withChallenge(int challenge) { return new InfoQuery(remoteAddress(), payload(), challenge); } + + public static InfoQuery read(InetSocketAddress remoteAddress, ByteBuf buffer) { + String payload = readString(buffer); + Integer challenge = buffer.readableBytes()>0?buffer.readIntLE():null; + return new InfoQuery(remoteAddress, payload, challenge); + } + public InfoQuery write(ByteBuf buffer) { + buffer.writeByte(OP); + writeString(buffer, payload()); + if(challenge() != null) buffer.writeIntLE(challenge()); + return this; + } +} diff --git a/src/main/java/com/gportal/source/query/messages/InfoReply.java b/src/main/java/com/gportal/source/query/messages/InfoReply.java new file mode 100644 index 0000000..712ce91 --- /dev/null +++ b/src/main/java/com/gportal/source/query/messages/InfoReply.java @@ -0,0 +1,22 @@ +package com.gportal.source.query.messages; + +import java.net.InetSocketAddress; + +import com.gportal.source.query.ServerInfo; +import com.gportal.source.query.Reply; + +import io.netty.buffer.ByteBuf; + +public record InfoReply(InetSocketAddress remoteAddress, ServerInfo payload) implements Reply { + public static final byte OP = 0x49; + + public static InfoReply read(InetSocketAddress remoteAddress, ByteBuf buffer) { + ServerInfo payload = ServerInfo.read(remoteAddress, buffer); + return new InfoReply(remoteAddress, payload); + } + public InfoReply write(ByteBuf buffer) { + buffer.writeByte(OP); + payload().write(buffer); + return this; + } +} diff --git a/src/main/java/com/gportal/source/query/messages/PlayerQuery.java b/src/main/java/com/gportal/source/query/messages/PlayerQuery.java new file mode 100644 index 0000000..f38c186 --- /dev/null +++ b/src/main/java/com/gportal/source/query/messages/PlayerQuery.java @@ -0,0 +1,24 @@ +package com.gportal.source.query.messages; + +import java.net.InetSocketAddress; + +import com.gportal.source.query.Query; + +import io.netty.buffer.ByteBuf; + +public record PlayerQuery(InetSocketAddress remoteAddress, Integer challenge) implements Query { + public static final byte OP = 0x55; + + public PlayerQuery withChallenge(int challenge) { return new PlayerQuery(remoteAddress(), challenge); } + + public static PlayerQuery read(InetSocketAddress remoteAddress, ByteBuf buffer) { + Integer challenge = buffer.readIntLE(); + if(challenge==0) challenge = null; + return new PlayerQuery(remoteAddress, challenge); + } + public PlayerQuery write(ByteBuf buffer) { + buffer.writeByte(OP); + buffer.writeIntLE(challenge()!=null?challenge():-1); + return this; + } +} diff --git a/src/main/java/com/gportal/source/query/messages/PlayerReply.java b/src/main/java/com/gportal/source/query/messages/PlayerReply.java new file mode 100644 index 0000000..edb8c3e --- /dev/null +++ b/src/main/java/com/gportal/source/query/messages/PlayerReply.java @@ -0,0 +1,31 @@ +package com.gportal.source.query.messages; + +import java.net.InetSocketAddress; +import java.util.ArrayList; +import java.util.List; + +import com.gportal.source.query.PlayerInfo; +import com.gportal.source.query.Reply; + +import io.netty.buffer.ByteBuf; + +public record PlayerReply(InetSocketAddress remoteAddress, List payload) implements Reply { + public static final byte OP = 0x44; + + public static PlayerReply read(InetSocketAddress remoteAddress, ByteBuf buffer) { + byte count = buffer.readByte(); + List payload = new ArrayList(); + for(int i = 0; i < count; i++) { + payload.add(PlayerInfo.read(buffer)); + } + return new PlayerReply(remoteAddress, payload); + } + public PlayerReply write(ByteBuf buffer) { + buffer.writeByte(OP); + buffer.writeByte(payload().size()); + payload().forEach(player -> { + player.write(buffer); + }); + return this; + } +} diff --git a/src/main/java/com/gportal/source/query/messages/RulesQuery.java b/src/main/java/com/gportal/source/query/messages/RulesQuery.java new file mode 100644 index 0000000..9cdd85d --- /dev/null +++ b/src/main/java/com/gportal/source/query/messages/RulesQuery.java @@ -0,0 +1,24 @@ +package com.gportal.source.query.messages; + +import java.net.InetSocketAddress; + +import com.gportal.source.query.Query; + +import io.netty.buffer.ByteBuf; + +public record RulesQuery(InetSocketAddress remoteAddress, Integer challenge) implements Query { + public static final byte OP = 0x56; + + public RulesQuery withChallenge(int challenge) { return new RulesQuery(remoteAddress(), challenge); } + + public static RulesQuery read(InetSocketAddress remoteAddress, ByteBuf buffer) { + Integer challenge = buffer.readIntLE(); + if(challenge==0) challenge = null; + return new RulesQuery(remoteAddress, challenge); + } + public RulesQuery write(ByteBuf buffer) { + buffer.writeByte(OP); + buffer.writeIntLE(challenge()!=null?challenge():-1); + return this; + } +} diff --git a/src/main/java/com/gportal/source/query/messages/RulesReply.java b/src/main/java/com/gportal/source/query/messages/RulesReply.java new file mode 100644 index 0000000..6e76c20 --- /dev/null +++ b/src/main/java/com/gportal/source/query/messages/RulesReply.java @@ -0,0 +1,34 @@ +package com.gportal.source.query.messages; + +import static com.gportal.source.query.Message.readString; +import static com.gportal.source.query.Message.writeString; + +import java.net.InetSocketAddress; +import java.util.HashMap; +import java.util.Map; + +import com.gportal.source.query.Reply; + +import io.netty.buffer.ByteBuf; + +public record RulesReply(InetSocketAddress remoteAddress, Map payload) implements Reply { + public static final byte OP = 0x45; + + public static RulesReply read(InetSocketAddress remoteAddress, ByteBuf buffer) { + short count = buffer.readShortLE(); + Map payload = new HashMap(); + for(int i = 0; i < count; i++) { + payload.put(readString(buffer), readString(buffer)); + } + return new RulesReply(remoteAddress, payload); + } + public RulesReply write(ByteBuf buffer) { + buffer.writeByte(OP); + buffer.writeShortLE(payload().entrySet().size()); + payload().entrySet().forEach(entry -> { + writeString(buffer, entry.getKey()); + writeString(buffer, entry.getValue()); + }); + return this; + } +} diff --git a/src/test/java/TestQueryClient.java b/src/test/java/TestQueryClient.java new file mode 100644 index 0000000..b003820 --- /dev/null +++ b/src/test/java/TestQueryClient.java @@ -0,0 +1,49 @@ +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.UnknownHostException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import com.gportal.source.query.PlayerInfo; +import com.gportal.source.query.QueryClient; +import com.gportal.source.query.QueryServer; +import com.gportal.source.query.ServerInfo; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +public class TestQueryClient { + private static QueryClient client; + private static QueryServer server; + + @BeforeAll + public static void initClientAndServer() { + client = new QueryClient(); + server = new QueryServer(27015, new ServerInfo(null, (byte)17, "ExampleServer", "ExampleMap", "ExampleFolder", "ExampleGame", (short)0, (byte)4, (byte)10, (byte)0, 'd', 'l', false, true, "1.0.0.0", null, null, null, null, null, null)); + server.rules.put("Hello", "World"); + server.players.add(new PlayerInfo((byte)0, "ExamplePlayer", (short)500, 60f)); + } + + @Test + public void testQueryServer() throws UnknownHostException, InterruptedException, ExecutionException, TimeoutException { + System.out.println(client.queryServer(new InetSocketAddress(InetAddress.getLocalHost(), 27015)).get(5000, TimeUnit.MILLISECONDS)); + } + + @Test + public void testQueryPlayers() throws UnknownHostException, InterruptedException, ExecutionException, TimeoutException { + System.out.println(client.queryPlayers(new InetSocketAddress(InetAddress.getLocalHost(), 27015)).get(5000, TimeUnit.MILLISECONDS)); + } + + @Test + public void testQueryRules() throws UnknownHostException, InterruptedException, ExecutionException, TimeoutException { + System.out.println(client.queryRules(new InetSocketAddress(InetAddress.getLocalHost(), 27015)).get(5000, TimeUnit.MILLISECONDS)); + } + + @AfterAll + public static void cleanUp() { + client.shutdown(); + server.shutdown(); + } +} diff --git a/src/test/java/TestQueryServer.java b/src/test/java/TestQueryServer.java new file mode 100644 index 0000000..89e08ab --- /dev/null +++ b/src/test/java/TestQueryServer.java @@ -0,0 +1,49 @@ +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.UnknownHostException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import com.gportal.source.query.PlayerInfo; +import com.gportal.source.query.QueryClient; +import com.gportal.source.query.QueryServer; +import com.gportal.source.query.ServerInfo; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +public class TestQueryServer { + private static QueryClient client; + private static QueryServer server; + + @BeforeAll + public static void initClientAndServer() { + client = new QueryClient(); + server = new QueryServer(27017, new ServerInfo(null, (byte)17, "ExampleServer", "ExampleMap", "ExampleFolder", "ExampleGame", (short)0, (byte)4, (byte)10, (byte)0, 'd', 'l', false, true, "1.0.0.0", null, null, null, null, null, null)); + server.rules.put("Hello", "World"); + server.players.add(new PlayerInfo((byte)0, "ExamplePlayer", (short)500, 60f)); + } + + @Test + public void testQueryServer() throws UnknownHostException, InterruptedException, ExecutionException, TimeoutException { + System.out.println(client.queryServer(new InetSocketAddress(InetAddress.getLocalHost(), 27017)).get(5000, TimeUnit.MILLISECONDS)); + } + + @Test + public void testQueryPlayers() throws UnknownHostException, InterruptedException, ExecutionException, TimeoutException { + System.out.println(client.queryPlayers(new InetSocketAddress(InetAddress.getLocalHost(), 27017)).get(5000, TimeUnit.MILLISECONDS)); + } + + @Test + public void testQueryRules() throws UnknownHostException, InterruptedException, ExecutionException, TimeoutException { + System.out.println(client.queryRules(new InetSocketAddress(InetAddress.getLocalHost(), 27017)).get(5000, TimeUnit.MILLISECONDS)); + } + + @AfterAll + public static void cleanUp() { + client.shutdown(); + server.shutdown(); + } +} From c0484255718be9a5fcceae82ad0df7188fa18110 Mon Sep 17 00:00:00 2001 From: Alexander Birkner Date: Wed, 14 Jan 2026 09:05:19 +0100 Subject: [PATCH 2/3] Added pipeline for package --- .github/workflows/ci-cd.yml | 40 +++++++++++++++++++++++++++++++++++++ README.md | 31 +++++++++++++++++++++++----- pom.xml | 8 ++++++++ 3 files changed, 74 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/ci-cd.yml diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml new file mode 100644 index 0000000..c1fa82c --- /dev/null +++ b/.github/workflows/ci-cd.yml @@ -0,0 +1,40 @@ +name: Java CI/CD + +on: + push: + tags: + - '*' + +jobs: + build-and-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 25 + uses: actions/setup-java@v4 + with: + java-version: '25' + distribution: 'temurin' + cache: maven + - name: Build with Maven + run: mvn -B package -Drevision=${{ github.ref_name }} --file pom.xml + + publish: + needs: build-and-test + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 25 + uses: actions/setup-java@v4 + with: + java-version: '25' + distribution: 'temurin' + server-id: github + settings-path: ${{ github.workspace }} + - name: Publish to GitHub Packages + run: mvn -B deploy -DskipTests -Drevision=${{ github.ref_name }} --file pom.xml + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index 47afab9..2cf27c9 100644 --- a/README.md +++ b/README.md @@ -17,27 +17,48 @@ This library allows you to query game servers that implement the Source Engine Q ## Installation -The library is published on the Maven registry. You can include it in your project using Maven or Gradle. +The library is published on GitHub Packages. To use it, you need to configure your build tool to include the GitHub Maven repository. ### Maven -Add the following dependency to your `pom.xml`: +1. Add the following repository to your `pom.xml`: + +```xml + + + github + https://maven.pkg.github.com/g-portal/a2s-java + + +``` + +2. Add the following dependency to your `pom.xml`: ```xml com.gportal a2s - 1.0.0-SNAPSHOT + 1.0.0 ``` ### Gradle -Add the following to your `build.gradle`: +1. Add the following repository to your `build.gradle`: + +```gradle +repositories { + maven { + url = uri("https://maven.pkg.github.com/g-portal/a2s-java") + } +} +``` + +2. Add the following to your `build.gradle` dependencies: ```gradle dependencies { - implementation 'com.gportal:a2s:' + implementation 'com.gportal:a2s:1.0.0' // Use the desired release tag version } ``` diff --git a/pom.xml b/pom.xml index ce2601f..abcf46a 100644 --- a/pom.xml +++ b/pom.xml @@ -15,6 +15,14 @@ UTF-8 + + + github + GitHub Packages + https://maven.pkg.github.com/g-portal/a2s-java + + + io.netty From f53c115b1842244c998bd3b48f7afbc5fad695ee Mon Sep 17 00:00:00 2001 From: Alexander Birkner Date: Wed, 14 Jan 2026 09:07:05 +0100 Subject: [PATCH 3/3] Run tests on pull request --- .github/workflows/ci-cd.yml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index c1fa82c..1b29d8d 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -4,6 +4,9 @@ on: push: tags: - '*' + pull_request: + branches: + - '**' jobs: build-and-test: @@ -16,10 +19,18 @@ jobs: java-version: '25' distribution: 'temurin' cache: maven + - name: Set version + run: | + if [[ $GITHUB_REF == refs/tags/* ]]; then + echo "REVISION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV + else + echo "REVISION=0.0.0-SNAPSHOT" >> $GITHUB_ENV + fi - name: Build with Maven - run: mvn -B package -Drevision=${{ github.ref_name }} --file pom.xml + run: mvn -B package -Drevision=${{ env.REVISION }} --file pom.xml publish: + if: startsWith(github.ref, 'refs/tags/') needs: build-and-test runs-on: ubuntu-latest permissions: