From 3f3f980ce2019e12577dab84b708b1302e98d468 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Wed, 25 Mar 2026 17:48:45 +0100 Subject: [PATCH] test(protocol): add binary protocol coverage and integration checks --- tests/core/BinaryCodec.test.ts | 385 ++++++++++++++++++ tests/server/GameServerBinaryProtocol.test.ts | 203 +++++++++ 2 files changed, 588 insertions(+) create mode 100644 tests/core/BinaryCodec.test.ts create mode 100644 tests/server/GameServerBinaryProtocol.test.ts diff --git a/tests/core/BinaryCodec.test.ts b/tests/core/BinaryCodec.test.ts new file mode 100644 index 000000000..1ec8a307c --- /dev/null +++ b/tests/core/BinaryCodec.test.ts @@ -0,0 +1,385 @@ +import { describe, expect, it } from "vitest"; +import { + binaryContextFromGameStartInfo, + decodeBinaryClientGameplayMessage, + decodeBinaryServerGameplayMessage, + encodeBinaryClientGameplayMessage, + encodeBinaryServerGameplayMessage, +} from "../../src/core/BinaryCodec"; +import { BINARY_PROTOCOL_VERSION } from "../../src/core/BinaryProtocol"; +import { + ClientHashMessage, + ClientIntentMessage, + ClientPingMessage, + QuickChatKeySchema, + ServerDesyncMessage, + ServerTurnMessage, +} from "../../src/core/Schemas"; +import { AllPlayers, UnitType } from "../../src/core/game/Game"; + +const quickChatKey = QuickChatKeySchema.options[0]; + +const context = binaryContextFromGameStartInfo({ + players: [ + { clientID: "P0000001" }, + { clientID: "P0000002" }, + { clientID: "P0000003" }, + ], +} as any); + +const clientIntentMessages: ClientIntentMessage[] = [ + { + type: "intent", + intent: { + type: "attack", + targetID: "P0000002", + troops: 12.5, + }, + }, + { + type: "intent", + intent: { + type: "attack", + targetID: null, + troops: null, + }, + }, + { + type: "intent", + intent: { + type: "cancel_attack", + attackID: "attack-123", + }, + }, + { + type: "intent", + intent: { + type: "spawn", + tile: 42, + }, + }, + { + type: "intent", + intent: { + type: "mark_disconnected", + clientID: "P0000002", + isDisconnected: true, + }, + }, + { + type: "intent", + intent: { + type: "boat", + troops: 99, + dst: 123, + }, + }, + { + type: "intent", + intent: { + type: "cancel_boat", + unitID: 77, + }, + }, + { + type: "intent", + intent: { + type: "allianceRequest", + recipient: "P0000002", + }, + }, + { + type: "intent", + intent: { + type: "allianceReject", + requestor: "P0000002", + }, + }, + { + type: "intent", + intent: { + type: "breakAlliance", + recipient: "P0000002", + }, + }, + { + type: "intent", + intent: { + type: "targetPlayer", + target: "P0000002", + }, + }, + { + type: "intent", + intent: { + type: "emoji", + recipient: "P0000002", + emoji: 3, + }, + }, + { + type: "intent", + intent: { + type: "emoji", + recipient: AllPlayers, + emoji: 4, + }, + }, + { + type: "intent", + intent: { + type: "donate_gold", + recipient: "P0000002", + gold: 250, + }, + }, + { + type: "intent", + intent: { + type: "donate_gold", + recipient: "P0000002", + gold: null, + }, + }, + { + type: "intent", + intent: { + type: "donate_troops", + recipient: "P0000002", + troops: 25, + }, + }, + { + type: "intent", + intent: { + type: "donate_troops", + recipient: "P0000002", + troops: null, + }, + }, + { + type: "intent", + intent: { + type: "build_unit", + unit: UnitType.Warship, + tile: 7, + rocketDirectionUp: true, + }, + }, + { + type: "intent", + intent: { + type: "build_unit", + unit: UnitType.Port, + tile: 8, + }, + }, + { + type: "intent", + intent: { + type: "upgrade_structure", + unit: UnitType.City, + unitId: 9, + }, + }, + { + type: "intent", + intent: { + type: "embargo", + targetID: "P0000002", + action: "start", + }, + }, + { + type: "intent", + intent: { + type: "embargo_all", + action: "stop", + }, + }, + { + type: "intent", + intent: { + type: "move_warship", + unitId: 55, + tile: 88, + }, + }, + { + type: "intent", + intent: { + type: "quick_chat", + recipient: "P0000002", + quickChatKey, + target: "P0000003", + }, + }, + { + type: "intent", + intent: { + type: "quick_chat", + recipient: "P0000002", + quickChatKey, + }, + }, + { + type: "intent", + intent: { + type: "allianceExtension", + recipient: "P0000002", + }, + }, + { + type: "intent", + intent: { + type: "delete_unit", + unitId: 101, + }, + }, + { + type: "intent", + intent: { + type: "toggle_pause", + paused: true, + }, + }, +]; + +describe("BinaryCodec", () => { + it.each(clientIntentMessages)( + "round-trips client gameplay intent %#", + (message) => { + const encoded = encodeBinaryClientGameplayMessage(message, context); + const decoded = decodeBinaryClientGameplayMessage(encoded, context); + expect(decoded).toEqual(message); + }, + ); + + it("round-trips hash messages", () => { + const message: ClientHashMessage = { + type: "hash", + turnNumber: 12, + hash: 34567, + }; + const encoded = encodeBinaryClientGameplayMessage(message, context); + const decoded = decodeBinaryClientGameplayMessage(encoded, context); + expect(decoded).toEqual(message); + }); + + it("round-trips ping messages", () => { + const message: ClientPingMessage = { + type: "ping", + }; + const encoded = encodeBinaryClientGameplayMessage(message, context); + const decoded = decodeBinaryClientGameplayMessage(encoded, context); + expect(decoded).toEqual(message); + }); + + it("round-trips server turn messages", () => { + const message: ServerTurnMessage = { + type: "turn", + turn: { + turnNumber: 5, + intents: [ + { + type: "spawn", + tile: 10, + clientID: "P0000001", + }, + { + type: "emoji", + recipient: AllPlayers, + emoji: 2, + clientID: "P0000002", + }, + ], + }, + }; + const encoded = encodeBinaryServerGameplayMessage(message, context); + const decoded = decodeBinaryServerGameplayMessage(encoded, context); + expect(decoded).toEqual(message); + }); + + it("round-trips server desync messages", () => { + const message: ServerDesyncMessage = { + type: "desync", + turn: 9, + correctHash: 777, + clientsWithCorrectHash: 3, + totalActiveClients: 4, + }; + const encoded = encodeBinaryServerGameplayMessage(message, context); + const decoded = decodeBinaryServerGameplayMessage(encoded, context); + expect(decoded).toEqual(message); + }); + + it("rejects unknown protocol versions", () => { + const encoded = encodeBinaryClientGameplayMessage( + { + type: "ping", + }, + context, + ); + encoded[0] = BINARY_PROTOCOL_VERSION + 1; + expect(() => decodeBinaryClientGameplayMessage(encoded, context)).toThrow( + /Unsupported binary protocol version/, + ); + }); + + it("rejects invalid player indexes", () => { + const encoded = encodeBinaryServerGameplayMessage( + { + type: "turn", + turn: { + turnNumber: 1, + intents: [ + { + type: "spawn", + tile: 3, + clientID: "P0000001", + }, + ], + }, + }, + context, + ); + encoded[10] = 99; + expect(() => decodeBinaryServerGameplayMessage(encoded, context)).toThrow( + /Invalid player index/, + ); + }); + + it("rejects invalid intent flags", () => { + const encoded = encodeBinaryClientGameplayMessage( + { + type: "intent", + intent: { + type: "spawn", + tile: 1, + }, + }, + context, + ); + encoded[5] = 0x04; + expect(() => decodeBinaryClientGameplayMessage(encoded, context)).toThrow( + /Unsupported flags/, + ); + }); + + it("rejects truncated frames", () => { + const encoded = encodeBinaryServerGameplayMessage( + { + type: "desync", + turn: 4, + correctHash: null, + clientsWithCorrectHash: 1, + totalActiveClients: 2, + }, + context, + ); + const truncated = encoded.subarray(0, encoded.length - 1); + expect(() => decodeBinaryServerGameplayMessage(truncated, context)).toThrow( + /Unexpected end of binary frame/, + ); + }); +}); diff --git a/tests/server/GameServerBinaryProtocol.test.ts b/tests/server/GameServerBinaryProtocol.test.ts new file mode 100644 index 000000000..a72a2e4bb --- /dev/null +++ b/tests/server/GameServerBinaryProtocol.test.ts @@ -0,0 +1,203 @@ +import { EventEmitter } from "events"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("../../src/server/Archive", () => ({ + archive: vi.fn(), + finalizeGameRecord: vi.fn((record) => record), +})); + +import { + binaryContextFromGameStartInfo, + decodeBinaryServerGameplayMessage, + encodeBinaryClientGameplayMessage, +} from "../../src/core/BinaryCodec"; +import { + Difficulty, + GameMapSize, + GameMapType, + GameMode, + GameType, +} from "../../src/core/game/Game"; +import type { GameConfig } from "../../src/core/Schemas"; +import { Client } from "../../src/server/Client"; +import { GameServer } from "../../src/server/GameServer"; + +class MockWebSocket extends EventEmitter { + public readonly sent: Array = []; + public readyState = 1; + + send(message: string | Uint8Array) { + this.sent.push(message); + } + + close(_code?: number, _reason?: string) { + this.readyState = 3; + this.emit("close"); + } +} + +function createMockLogger() { + return { + child: vi.fn().mockReturnThis(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; +} + +function createGameConfig(): GameConfig { + return { + donateGold: false, + donateTroops: false, + gameMap: GameMapType.World, + gameType: GameType.Private, + gameMapSize: GameMapSize.Normal, + difficulty: Difficulty.Easy, + nations: "default", + infiniteGold: false, + infiniteTroops: false, + instantBuild: false, + randomSpawn: false, + gameMode: GameMode.FFA, + bots: 0, + disabledUnits: [], + }; +} + +function createClient( + clientID: string, + persistentID: string, + username: string, +) { + const ws = new MockWebSocket(); + const client = new Client( + clientID as any, + persistentID, + null, + undefined, + undefined, + "127.0.0.1", + username, + null, + ws as any, + undefined, + ); + return { client, ws }; +} + +describe("GameServer binary gameplay protocol", () => { + it("keeps start JSON and emits live turns as binary", async () => { + const logger = createMockLogger(); + const game = new GameServer( + "TEST0001", + logger as any, + Date.now(), + { + turnIntervalMs: () => 100, + env: () => 0, + } as any, + createGameConfig(), + ); + + const clientA = createClient( + "P0000001", + "11111111-1111-4111-8111-111111111111", + "Alice", + ); + const clientB = createClient( + "P0000002", + "22222222-2222-4222-8222-222222222222", + "Bob", + ); + + expect(game.joinClient(clientA.client)).toBe("joined"); + expect(game.joinClient(clientB.client)).toBe("joined"); + + game.start(); + + const startPayload = clientA.ws.sent.find( + (message): message is string => + typeof message === "string" && message.includes('"type":"start"'), + ); + expect(startPayload).toBeDefined(); + + const binaryContext = binaryContextFromGameStartInfo( + JSON.parse(startPayload!).gameStartInfo, + ); + const spawnMessage = encodeBinaryClientGameplayMessage( + { + type: "intent", + intent: { + type: "spawn", + tile: 123, + }, + }, + binaryContext, + ); + + clientA.ws.emit("message", spawnMessage, true); + (game as any).endTurn(); + + const binaryTurn = clientA.ws.sent.find( + (message): message is Uint8Array => + message instanceof Uint8Array && message[1] === 2, + ); + expect(binaryTurn).toBeDefined(); + + const decodedTurn = decodeBinaryServerGameplayMessage( + binaryTurn!, + binaryContext, + ); + expect(decodedTurn.type).toBe("turn"); + if (decodedTurn.type !== "turn") { + throw new Error("Expected binary turn message"); + } + expect(decodedTurn.turn.intents).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "spawn", + tile: 123, + clientID: "P0000001", + }), + ]), + ); + }); + + it("accepts JSON rejoin after start and responds with JSON start", () => { + const logger = createMockLogger(); + const game = new GameServer( + "TEST0002", + logger as any, + Date.now(), + { + turnIntervalMs: () => 100, + env: () => 0, + } as any, + createGameConfig(), + ); + + const clientA = createClient( + "P0000001", + "33333333-3333-4333-8333-333333333333", + "Alice", + ); + expect(game.joinClient(clientA.client)).toBe("joined"); + game.start(); + + clientA.ws.sent.length = 0; + clientA.ws.emit( + "message", + JSON.stringify({ + type: "rejoin", + gameID: "TEST0002", + lastTurn: 0, + token: "33333333-3333-4333-8333-333333333333", + }), + false, + ); + + expect(clientA.ws.sent).toHaveLength(1); + expect(typeof clientA.ws.sent[0]).toBe("string"); + expect(clientA.ws.sent[0]).toContain('"type":"start"'); + }); +});