diff --git a/core/pom.xml b/core/pom.xml index 6a8de2f..39b58fc 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -17,6 +17,13 @@ 17 + + + jitpack.io + https://jitpack.io + + + @@ -24,5 +31,10 @@ gson 2.9.0 + + com.github.andrewlalis + record-net + 1.1.0 + \ No newline at end of file diff --git a/core/src/main/java/nl/andrewl/starship_arena/core/net/ChatSend.java b/core/src/main/java/nl/andrewl/starship_arena/core/net/ChatSend.java new file mode 100644 index 0000000..c6cd5a4 --- /dev/null +++ b/core/src/main/java/nl/andrewl/starship_arena/core/net/ChatSend.java @@ -0,0 +1,5 @@ +package nl.andrewl.starship_arena.core.net; + +import nl.andrewl.record_net.Message; + +public record ChatSend(String msg) implements Message {} diff --git a/core/src/main/java/nl/andrewl/starship_arena/core/net/ChatSent.java b/core/src/main/java/nl/andrewl/starship_arena/core/net/ChatSent.java new file mode 100644 index 0000000..cddf374 --- /dev/null +++ b/core/src/main/java/nl/andrewl/starship_arena/core/net/ChatSent.java @@ -0,0 +1,7 @@ +package nl.andrewl.starship_arena.core.net; + +import nl.andrewl.record_net.Message; + +import java.util.UUID; + +public record ChatSent(UUID clientId, long timestamp, String msg) implements Message {} diff --git a/core/src/main/java/nl/andrewl/starship_arena/core/net/ClientConnectRequest.java b/core/src/main/java/nl/andrewl/starship_arena/core/net/ClientConnectRequest.java new file mode 100644 index 0000000..56d96e2 --- /dev/null +++ b/core/src/main/java/nl/andrewl/starship_arena/core/net/ClientConnectRequest.java @@ -0,0 +1,10 @@ +package nl.andrewl.starship_arena.core.net; + +import nl.andrewl.record_net.Message; + +import java.util.UUID; + +public record ClientConnectRequest( + UUID arenaId, + UUID clientId +) implements Message {} diff --git a/core/src/main/java/nl/andrewl/starship_arena/core/net/ClientConnectResponse.java b/core/src/main/java/nl/andrewl/starship_arena/core/net/ClientConnectResponse.java new file mode 100644 index 0000000..f61695d --- /dev/null +++ b/core/src/main/java/nl/andrewl/starship_arena/core/net/ClientConnectResponse.java @@ -0,0 +1,7 @@ +package nl.andrewl.starship_arena.core.net; + +import nl.andrewl.record_net.Message; + +import java.util.UUID; + +public record ClientConnectResponse(boolean success, UUID clientId) implements Message {} diff --git a/core/src/main/java/nl/andrewl/starship_arena/core/util/ResourceUtils.java b/core/src/main/java/nl/andrewl/starship_arena/core/util/ResourceUtils.java index 721d978..ca5314a 100644 --- a/core/src/main/java/nl/andrewl/starship_arena/core/util/ResourceUtils.java +++ b/core/src/main/java/nl/andrewl/starship_arena/core/util/ResourceUtils.java @@ -5,12 +5,24 @@ import java.io.InputStream; import java.io.UncheckedIOException; public class ResourceUtils { + /** + * Gets an input stream for the given resource on the classpath. + * @param name The name of the resource. + * @return An input stream to read the resource. + * @throws UncheckedIOException If the resource could not be loaded. + */ public static InputStream get(String name) { InputStream in = ResourceUtils.class.getResourceAsStream(name); if (in == null) throw new UncheckedIOException(new IOException("Could not load resource: " + name)); return in; } + /** + * Gets a classpath resource as a string. + * @param name The name of the resource. + * @return The string representation of that resource. + * @throws UncheckedIOException If the resource could not be loaded. + */ public static String getString(String name) { try (var in = get(name)) { return new String(in.readAllBytes()); diff --git a/server/protocol.md b/server/protocol.md new file mode 100644 index 0000000..dcf814c --- /dev/null +++ b/server/protocol.md @@ -0,0 +1,69 @@ +# Client - Server Communication Protocol +This document describes the format of messages that will be sent back and forth between starship arena clients and servers. It is organized according to the lifecycle of an arena. + +## Connecting +Clients connect to an arena by connecting to the starship arena server's **gateway TCP socket**. Upon connecting, clients must immediately send the following information: +``` +arenaId: UUID (16 bytes), +reconnecting: boolean (1 byte), +if reconnecting: + clientId: UUID (16 bytes) +``` +The `arenaId` is the unique id of the arena that the client is attempting to join. `reconnecting` indicates to the server if the client is trying to reconnect to an arena that they already joined, and if so, the server expects the client to send a `clientId` which is their unique id which they were assigned when first joining. + +If the connection was successful, the server will respond: +``` +success: boolean (1 byte), +if success: + clientId: UUID (16 bytes) +``` +If the client receives `success == true`, then they have been successfully connected and should expect to read their `clientId` (this is provided even if the client is reconnecting, for simplicity). If the client receives `success == false`, then they can close the socket. The server will close the socket immediately after a failed connection. + +### General Message Information +Unless otherwise specified, all client/server messages are always prefixed with a standard header: +``` +messageType: byte, +messageSize: int (4 bytes) +``` +The `messageType` indicates what type of message was sent, and what structure it will have. The `messageSize` indicates the size, in bytes, of the message, excluding the header data itself. + +### Chat Messages +At any point while a client is connected to an arena, it may send and receive chat messages. To send a chat message, a client sends the following: +``` +messageType: 1, +messageSize: msg.length(), +msg: String +``` +Chat messages will be relayed by the arena to all clients using the following format: +``` +messageType: 2, +messageSize: 16 + 8 + msg.length(), +clientId: UUID (16 bytes), +timestamp: long (8 bytes), +msg: String +``` + +### Error Messages +Generally, error messages take the following form: +``` +messageType: 0, +messageSize: msg.length(), +msg: String +``` +They can be sent by the server as a response to any client message that prompted that error. + +## Staging +During the staging part of the arena lifecycle, clients can of course chat, but they can also send commands to configure their experience prior to the start of the game. This includes the following: +- Setting their username +- Choosing to be a spectator or a player (all clients are spectators by default) +- Choosing a team to be on (if choice is allowed, and if teams exist) +- Choosing a spacecraft model (if choice is allowed) +- Fetch arena configuration settings + +### Client Config Message +This message is sent by the client to indicate that they'd like to change their current configuration. +``` +messageType: 10, +messageSize: see below, +role: byte (0 = spectator, 1 - 128 = player or team number) +``` diff --git a/server/src/main/java/nl/andrewl/starship_arena/server/ClientManager.java b/server/src/main/java/nl/andrewl/starship_arena/server/ClientManager.java index a5a0fa3..1e82457 100644 --- a/server/src/main/java/nl/andrewl/starship_arena/server/ClientManager.java +++ b/server/src/main/java/nl/andrewl/starship_arena/server/ClientManager.java @@ -1,22 +1,45 @@ package nl.andrewl.starship_arena.server; import lombok.Getter; +import nl.andrewl.record_net.Message; +import nl.andrewl.starship_arena.core.net.ChatSend; import nl.andrewl.starship_arena.server.model.Arena; +import nl.andrewl.starship_arena.server.model.ChatMessage; -import java.io.IOException; +import java.io.*; import java.net.Socket; import java.util.UUID; +import static nl.andrewl.starship_arena.server.SocketGateway.SERIALIZER; + public class ClientManager extends Thread { - private final Arena arena; - private final Socket clientSocket; @Getter private final UUID clientId; + @Getter + private String clientUsername; + private final Arena arena; - public ClientManager(Arena arena, Socket clientSocket, UUID id) { + private final Socket clientSocket; + private final InputStream in; + private final OutputStream out; + private final DataInputStream dIn; + private final DataOutputStream dOut; + + public ClientManager(Arena arena, Socket clientSocket, UUID id) throws IOException { this.arena = arena; this.clientSocket = clientSocket; this.clientId = id; + this.in = clientSocket.getInputStream(); + this.out = clientSocket.getOutputStream(); + this.dIn = new DataInputStream(clientSocket.getInputStream()); + this.dOut = new DataOutputStream(clientSocket.getOutputStream()); + } + + public void send(byte[] data) throws IOException { + synchronized (out) { + out.write(data); + out.flush(); + } } public void shutdown() { @@ -30,7 +53,14 @@ public class ClientManager extends Thread { @Override public void run() { while (!clientSocket.isClosed()) { - + try { + Message msg = SERIALIZER.readMessage(in); + if (msg instanceof ChatSend cs) { + arena.chatSent(new ChatMessage(clientId, System.currentTimeMillis(), cs.msg())); + } + } catch (IOException e) { + e.printStackTrace(); + } } } } diff --git a/server/src/main/java/nl/andrewl/starship_arena/server/SocketGateway.java b/server/src/main/java/nl/andrewl/starship_arena/server/SocketGateway.java index 4702ff4..d29c86c 100644 --- a/server/src/main/java/nl/andrewl/starship_arena/server/SocketGateway.java +++ b/server/src/main/java/nl/andrewl/starship_arena/server/SocketGateway.java @@ -2,6 +2,10 @@ package nl.andrewl.starship_arena.server; import lombok.Getter; import lombok.extern.slf4j.Slf4j; +import nl.andrewl.record_net.Message; +import nl.andrewl.record_net.Serializer; +import nl.andrewl.starship_arena.core.net.ClientConnectRequest; +import nl.andrewl.starship_arena.core.net.ClientConnectResponse; import nl.andrewl.starship_arena.server.data.ArenaStore; import nl.andrewl.starship_arena.server.model.Arena; import org.springframework.beans.factory.annotation.Value; @@ -10,13 +14,8 @@ import org.springframework.context.event.EventListener; import org.springframework.stereotype.Service; import javax.annotation.PreDestroy; -import java.io.DataInputStream; -import java.io.DataOutputStream; import java.io.IOException; -import java.net.DatagramSocket; -import java.net.InetSocketAddress; -import java.net.ServerSocket; -import java.net.Socket; +import java.net.*; import java.util.UUID; /** @@ -27,6 +26,13 @@ import java.util.UUID; @Service @Slf4j public class SocketGateway implements Runnable { + public static final int INITIALIZATION_TIMEOUT = 1000; + public static final Serializer SERIALIZER = new Serializer(); + static { + SERIALIZER.registerType(1, ClientConnectRequest.class); + SERIALIZER.registerType(2, ClientConnectResponse.class); + } + private final ServerSocket serverSocket; private final DatagramSocket serverUdpSocket; private final ArenaStore arenaStore; @@ -90,28 +96,32 @@ public class SocketGateway implements Runnable { * @param clientSocket The socket to the client. */ private void processIncomingConnection(Socket clientSocket) { - try ( - var in = new DataInputStream(clientSocket.getInputStream()); - var out = new DataOutputStream(clientSocket.getOutputStream()) - ) { - UUID arenaId = new UUID(in.readLong(), in.readLong()); - UUID clientId; - boolean reconnecting = in.readBoolean(); - if (reconnecting) { - clientId = new UUID(in.readLong(), in.readLong()); - } else { - clientId = UUID.randomUUID(); + try { + clientSocket.setSoTimeout(INITIALIZATION_TIMEOUT); // Set limited timeout so new connections don't waste resources. + Message msg = SERIALIZER.readMessage(clientSocket.getInputStream()); + if (msg instanceof ClientConnectRequest cm) { + UUID arenaId = cm.arenaId(); + UUID clientId = cm.clientId(); + if (clientId == null) clientId = UUID.randomUUID(); + var oa = arenaStore.getById(arenaId); + if (oa.isPresent()) { + Arena arena = oa.get(); + ClientManager clientManager = new ClientManager(arena, clientSocket, clientId); + arena.registerClient(clientManager); + SERIALIZER.writeMessage(new ClientConnectResponse(true, clientId)); + clientSocket.setSoTimeout(0); // Reset timeout to infinity after successful initialization. + clientManager.start(); + return; + } } - var oa = arenaStore.getById(arenaId); - if (oa.isPresent()) { - Arena arena = oa.get(); - ClientManager clientManager = new ClientManager(arena, clientSocket, clientId); - arena.registerClient(clientManager); - out.writeBoolean(true); - clientManager.start(); - } else { - out.writeBoolean(false); + // If the connection wasn't valid, return a no-success response. + SERIALIZER.writeMessage(new ClientConnectResponse(false, null)); + clientSocket.close(); + } catch (SocketTimeoutException e) { + try { clientSocket.close(); + } catch (IOException ex) { + throw new RuntimeException(ex); } } catch (IOException e) { e.printStackTrace(); diff --git a/server/src/main/java/nl/andrewl/starship_arena/server/model/Arena.java b/server/src/main/java/nl/andrewl/starship_arena/server/model/Arena.java index 9a96a21..c7d8cff 100644 --- a/server/src/main/java/nl/andrewl/starship_arena/server/model/Arena.java +++ b/server/src/main/java/nl/andrewl/starship_arena/server/model/Arena.java @@ -1,11 +1,20 @@ package nl.andrewl.starship_arena.server.model; +import nl.andrewl.starship_arena.core.net.ChatSent; import nl.andrewl.starship_arena.server.ClientManager; +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.time.Instant; +import java.util.List; import java.util.Map; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; + +import static nl.andrewl.starship_arena.server.SocketGateway.SERIALIZER; public class Arena { private final UUID id; @@ -15,6 +24,7 @@ public class Arena { private ArenaStage currentStage = ArenaStage.STAGING; private final Map clients = new ConcurrentHashMap<>(); + private final List chatMessages = new CopyOnWriteArrayList<>(); public Arena(String name) { this.id = UUID.randomUUID(); @@ -50,6 +60,22 @@ public class Arena { } } + public void chatSent(ChatMessage chat) throws IOException { + chatMessages.add(chat); + byte[] data = SERIALIZER.writeMessage(new ChatSent(chat.clientId(), chat.timestamp(), chat.message())); + broadcast(data); + } + + private void broadcast(byte[] data) { + for (var cm : clients.values()) { + try { + cm.send(data); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } + @Override public boolean equals(Object o) { return o == this || o instanceof Arena a && id.equals(a.id); diff --git a/server/src/main/java/nl/andrewl/starship_arena/server/model/ChatMessage.java b/server/src/main/java/nl/andrewl/starship_arena/server/model/ChatMessage.java new file mode 100644 index 0000000..a218e06 --- /dev/null +++ b/server/src/main/java/nl/andrewl/starship_arena/server/model/ChatMessage.java @@ -0,0 +1,6 @@ +package nl.andrewl.starship_arena.server.model; + +import java.util.UUID; + +public record ChatMessage(UUID clientId, long timestamp, String message) { +}