test(protocol): add binary protocol coverage and integration checks

This commit is contained in:
scamiv
2026-03-25 17:48:45 +01:00
parent 003d8eb6bd
commit 3f3f980ce2
2 changed files with 588 additions and 0 deletions
+385
View File
@@ -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/,
);
});
});
@@ -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<string | Uint8Array> = [];
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"');
});
});