From 315e98ff284596c31ccaed44dd617fc4607e8e6d Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Wed, 25 Mar 2026 18:12:08 +0100 Subject: [PATCH] fix(protocol): support bot targets in binary player refs --- src/core/BinaryCodec.ts | 105 +++++++++++++----- src/core/BinaryProtocol.ts | 1 + tests/core/BinaryCodec.test.ts | 28 +++++ tests/server/GameServerBinaryProtocol.test.ts | 74 ++++++++++++ 4 files changed, 179 insertions(+), 29 deletions(-) diff --git a/src/core/BinaryCodec.ts b/src/core/BinaryCodec.ts index d62e20a5b..b4fa6337a 100644 --- a/src/core/BinaryCodec.ts +++ b/src/core/BinaryCodec.ts @@ -5,6 +5,7 @@ import { BinaryMessageType, BinaryProtocolContext, BinaryServerGameplayMessage, + INLINE_PLAYER_ID_INDEX, INTENT_FLAG_OPTION_A, INTENT_FLAG_OPTION_B, createBinaryProtocolContext, @@ -29,7 +30,7 @@ import { ServerTurnMessage, StampedIntent, } from "./Schemas"; -import { UnitType } from "./game/Game"; +import { AllPlayers, UnitType } from "./game/Game"; const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder(); @@ -460,7 +461,7 @@ function encodeIntent( switch (intent.type) { case "attack": if (intent.targetID !== null) { - writer.writeUint16(playerIdToIndex(intent.targetID, context)); + writePlayerRef(writer, intent.targetID, context); } if (intent.troops !== null) { writer.writeFloat64(intent.troops); @@ -473,7 +474,7 @@ function encodeIntent( writer.writeUint32(intent.tile); return; case "mark_disconnected": - writer.writeUint16(playerIdToIndex(intent.clientID, context)); + writeRequiredPlayerRef(writer, intent.clientID, context); writer.writeBoolean(intent.isDisconnected); return; case "boat": @@ -484,23 +485,23 @@ function encodeIntent( writer.writeUint32(intent.unitID); return; case "allianceRequest": - writer.writeUint16(playerIdToIndex(intent.recipient, context)); + writeRequiredPlayerRef(writer, intent.recipient, context); return; case "allianceReject": - writer.writeUint16(playerIdToIndex(intent.requestor, context)); + writeRequiredPlayerRef(writer, intent.requestor, context); return; case "breakAlliance": - writer.writeUint16(playerIdToIndex(intent.recipient, context)); + writeRequiredPlayerRef(writer, intent.recipient, context); return; case "targetPlayer": - writer.writeUint16(playerIdToIndex(intent.target, context)); + writeRequiredPlayerRef(writer, intent.target, context); return; case "emoji": - writer.writeUint16(playerIdToIndex(intent.recipient, context)); + writePlayerRef(writer, intent.recipient, context); writer.writeUint16(intent.emoji); return; case "donate_gold": - writer.writeUint16(playerIdToIndex(intent.recipient, context)); + writeRequiredPlayerRef(writer, intent.recipient, context); if (intent.gold === null) { writer.writeBoolean(false); } else { @@ -509,7 +510,7 @@ function encodeIntent( } return; case "donate_troops": - writer.writeUint16(playerIdToIndex(intent.recipient, context)); + writeRequiredPlayerRef(writer, intent.recipient, context); if (intent.troops === null) { writer.writeBoolean(false); } else { @@ -526,7 +527,7 @@ function encodeIntent( writer.writeUint32(intent.unitId); return; case "embargo": - writer.writeUint16(playerIdToIndex(intent.targetID, context)); + writeRequiredPlayerRef(writer, intent.targetID, context); writer.writeBoolean(intent.action === "start"); return; case "embargo_all": @@ -537,14 +538,14 @@ function encodeIntent( writer.writeUint32(intent.tile); return; case "quick_chat": - writer.writeUint16(playerIdToIndex(intent.recipient, context)); + writeRequiredPlayerRef(writer, intent.recipient, context); writer.writeString(intent.quickChatKey); if (intent.target !== undefined) { - writer.writeUint16(playerIdToIndex(intent.target, context)); + writeRequiredPlayerRef(writer, intent.target, context); } return; case "allianceExtension": - writer.writeUint16(playerIdToIndex(intent.recipient, context)); + writeRequiredPlayerRef(writer, intent.recipient, context); return; case "delete_unit": writer.writeUint32(intent.unitId); @@ -576,9 +577,7 @@ function decodeIntent( const hasTroops = (flags & INTENT_FLAG_OPTION_B) !== 0; return { type: "attack", - targetID: hasTarget - ? requireClientId(reader.readUint16(), context) - : null, + targetID: hasTarget ? readRequiredPlayerRef(reader, context) : null, troops: hasTroops ? reader.readFloat64() : null, }; } @@ -598,7 +597,7 @@ function decodeIntent( assertIntentFlags(intentType, flags, 0); return { type: "mark_disconnected", - clientID: requireClientId(reader.readUint16(), context), + clientID: readRequiredPlayerRef(reader, context), isDisconnected: reader.readBoolean(), }; case "boat": @@ -618,29 +617,29 @@ function decodeIntent( assertIntentFlags(intentType, flags, 0); return { type: "allianceRequest", - recipient: requireClientId(reader.readUint16(), context), + recipient: readRequiredPlayerRef(reader, context), }; case "allianceReject": assertIntentFlags(intentType, flags, 0); return { type: "allianceReject", - requestor: requireClientId(reader.readUint16(), context), + requestor: readRequiredPlayerRef(reader, context), }; case "breakAlliance": assertIntentFlags(intentType, flags, 0); return { type: "breakAlliance", - recipient: requireClientId(reader.readUint16(), context), + recipient: readRequiredPlayerRef(reader, context), }; case "targetPlayer": assertIntentFlags(intentType, flags, 0); return { type: "targetPlayer", - target: requireClientId(reader.readUint16(), context), + target: readRequiredPlayerRef(reader, context), }; case "emoji": { assertIntentFlags(intentType, flags, 0); - const recipient = playerIndexToId(reader.readUint16(), context); + const recipient = readPlayerRef(reader, context); if (recipient === null) { throw new Error("Emoji recipient cannot be null"); } @@ -652,7 +651,7 @@ function decodeIntent( } case "donate_gold": { assertIntentFlags(intentType, flags, 0); - const recipient = requireClientId(reader.readUint16(), context); + const recipient = readRequiredPlayerRef(reader, context); const hasGold = reader.readBoolean(); return { type: "donate_gold", @@ -662,7 +661,7 @@ function decodeIntent( } case "donate_troops": { assertIntentFlags(intentType, flags, 0); - const recipient = requireClientId(reader.readUint16(), context); + const recipient = readRequiredPlayerRef(reader, context); const hasTroops = reader.readBoolean(); return { type: "donate_troops", @@ -699,7 +698,7 @@ function decodeIntent( assertIntentFlags(intentType, flags, 0); return { type: "embargo", - targetID: requireClientId(reader.readUint16(), context), + targetID: readRequiredPlayerRef(reader, context), action: reader.readBoolean() ? "start" : "stop", }; case "embargo_all": @@ -717,11 +716,11 @@ function decodeIntent( }; case "quick_chat": { assertIntentFlags(intentType, flags, INTENT_FLAG_OPTION_A); - const recipient = requireClientId(reader.readUint16(), context); + const recipient = readRequiredPlayerRef(reader, context); const quickChatKey = reader.readString(); const target = (flags & INTENT_FLAG_OPTION_A) !== 0 - ? requireClientId(reader.readUint16(), context) + ? readRequiredPlayerRef(reader, context) : undefined; if (!QuickChatKeySchema.safeParse(quickChatKey).success) { throw new Error(`Invalid quick chat key: ${quickChatKey}`); @@ -737,7 +736,7 @@ function decodeIntent( assertIntentFlags(intentType, flags, 0); return { type: "allianceExtension", - recipient: requireClientId(reader.readUint16(), context), + recipient: readRequiredPlayerRef(reader, context), }; case "delete_unit": assertIntentFlags(intentType, flags, 0); @@ -773,6 +772,54 @@ function assertIntentFlags( } } +function writePlayerRef( + writer: BinaryWriter, + playerId: string | null | typeof AllPlayers, + context: BinaryProtocolContext, +) { + if (playerId === null || playerId === AllPlayers) { + writer.writeUint16(playerIdToIndex(playerId, context)); + return; + } + const mappedIndex = context.playerIdToIndex.get(playerId); + if (mappedIndex !== undefined) { + writer.writeUint16(mappedIndex); + return; + } + writer.writeUint16(INLINE_PLAYER_ID_INDEX); + writer.writeString(playerId); +} + +function writeRequiredPlayerRef( + writer: BinaryWriter, + playerId: string, + context: BinaryProtocolContext, +) { + writePlayerRef(writer, playerId, context); +} + +function readPlayerRef( + reader: BinaryReader, + context: BinaryProtocolContext, +): string | null | typeof AllPlayers { + const playerIndex = reader.readUint16(); + if (playerIndex === INLINE_PLAYER_ID_INDEX) { + return reader.readString(); + } + return playerIndexToId(playerIndex, context); +} + +function readRequiredPlayerRef( + reader: BinaryReader, + context: BinaryProtocolContext, +): string { + const playerId = readPlayerRef(reader, context); + if (playerId === null || playerId === AllPlayers) { + throw new Error(`Expected player ID, received ${String(playerId)}`); + } + return playerId; +} + export function isBinaryGameplayClientMessage( message: ClientMessage, ): message is BinaryClientGameplayMessage { diff --git a/src/core/BinaryProtocol.ts b/src/core/BinaryProtocol.ts index eab80c246..deff69cee 100644 --- a/src/core/BinaryProtocol.ts +++ b/src/core/BinaryProtocol.ts @@ -16,6 +16,7 @@ export const BINARY_PROTOCOL_VERSION = 1; export const BINARY_HEADER_SIZE = 4; export const NO_PLAYER_INDEX = 0xffff; export const ALL_PLAYERS_INDEX = 0xfffe; +export const INLINE_PLAYER_ID_INDEX = 0xfffd; export enum BinaryMessageType { Intent = 1, diff --git a/tests/core/BinaryCodec.test.ts b/tests/core/BinaryCodec.test.ts index 1ec8a307c..fb5c55e0e 100644 --- a/tests/core/BinaryCodec.test.ts +++ b/tests/core/BinaryCodec.test.ts @@ -36,6 +36,14 @@ const clientIntentMessages: ClientIntentMessage[] = [ troops: 12.5, }, }, + { + type: "intent", + intent: { + type: "attack", + targetID: "kli0dx59", + troops: 18, + }, + }, { type: "intent", intent: { @@ -300,6 +308,26 @@ describe("BinaryCodec", () => { expect(decoded).toEqual(message); }); + it("round-trips server turn messages with non-lobby target ids", () => { + const message: ServerTurnMessage = { + type: "turn", + turn: { + turnNumber: 6, + intents: [ + { + type: "attack", + targetID: "kli0dx59", + troops: 9, + clientID: "P0000001", + }, + ], + }, + }; + 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", diff --git a/tests/server/GameServerBinaryProtocol.test.ts b/tests/server/GameServerBinaryProtocol.test.ts index a72a2e4bb..cc05bfda8 100644 --- a/tests/server/GameServerBinaryProtocol.test.ts +++ b/tests/server/GameServerBinaryProtocol.test.ts @@ -163,6 +163,80 @@ describe("GameServer binary gameplay protocol", () => { ); }); + it("round-trips binary attacks that target bot-style ids", () => { + const logger = createMockLogger(); + const game = new GameServer( + "TEST0003", + logger as any, + Date.now(), + { + turnIntervalMs: () => 100, + env: () => 0, + } as any, + createGameConfig(), + ); + + const clientA = createClient( + "P0000001", + "44444444-4444-4444-8444-444444444444", + "Alice", + ); + + expect(game.joinClient(clientA.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 attackMessage = encodeBinaryClientGameplayMessage( + { + type: "intent", + intent: { + type: "attack", + targetID: "kli0dx59", + troops: 25, + }, + }, + binaryContext, + ); + + clientA.ws.emit("message", attackMessage, true); + (game as any).endTurn(); + + const binaryTurn = [...clientA.ws.sent] + .reverse() + .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: "attack", + targetID: "kli0dx59", + troops: 25, + clientID: "P0000001", + }), + ]), + ); + }); + it("accepts JSON rejoin after start and responds with JSON start", () => { const logger = createMockLogger(); const game = new GameServer(