mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-22 18:46:40 +00:00
test(protocol): add binary protocol coverage and integration checks
This commit is contained in:
@@ -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"');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user