diff --git a/src/core/BinaryCodec.ts b/src/core/BinaryCodec.ts index b4fa6337a..780789328 100644 --- a/src/core/BinaryCodec.ts +++ b/src/core/BinaryCodec.ts @@ -9,6 +9,7 @@ import { INTENT_FLAG_OPTION_A, INTENT_FLAG_OPTION_B, createBinaryProtocolContext, + hasBinaryIntentOpcode, intentTypeToOpcode, opcodeToIntentType, opcodeToUnitType, @@ -554,6 +555,8 @@ function encodeIntent( writer.writeBoolean(intent.paused); return; case "kick_player": + writeRequiredPlayerRef(writer, intent.target, context); + return; case "update_game_config": throw new Error(`Unsupported binary intent type: ${intent.type}`); } @@ -750,6 +753,12 @@ function decodeIntent( type: "toggle_pause", paused: reader.readBoolean(), }; + case "kick_player": + assertIntentFlags(intentType, flags, 0); + return { + type: "kick_player", + target: readRequiredPlayerRef(reader, context), + }; } throw new Error(`Unhandled binary intent type: ${intentType}`); @@ -824,7 +833,7 @@ export function isBinaryGameplayClientMessage( message: ClientMessage, ): message is BinaryClientGameplayMessage { return ( - message.type === "intent" || + (message.type === "intent" && hasBinaryIntentOpcode(message.intent.type)) || message.type === "hash" || message.type === "ping" ); diff --git a/src/core/BinaryProtocol.ts b/src/core/BinaryProtocol.ts index deff69cee..71b8727da 100644 --- a/src/core/BinaryProtocol.ts +++ b/src/core/BinaryProtocol.ts @@ -49,6 +49,7 @@ export enum BinaryIntentType { AllianceExtension = 20, DeleteUnit = 21, TogglePause = 22, + KickPlayer = 23, } export const INTENT_FLAG_OPTION_A = 1 << 0; @@ -88,7 +89,7 @@ const INTENT_TYPE_TO_OPCODE: Record< quick_chat: BinaryIntentType.QuickChat, allianceExtension: BinaryIntentType.AllianceExtension, delete_unit: BinaryIntentType.DeleteUnit, - kick_player: undefined, + kick_player: BinaryIntentType.KickPlayer, toggle_pause: BinaryIntentType.TogglePause, update_game_config: undefined, }; @@ -116,6 +117,7 @@ const OPCODE_TO_INTENT_TYPE: Record = { [BinaryIntentType.AllianceExtension]: "allianceExtension", [BinaryIntentType.DeleteUnit]: "delete_unit", [BinaryIntentType.TogglePause]: "toggle_pause", + [BinaryIntentType.KickPlayer]: "kick_player", }; const UNIT_TYPE_TO_OPCODE = new Map( @@ -155,6 +157,10 @@ export function intentTypeToOpcode( return opcode; } +export function hasBinaryIntentOpcode(intentType: Intent["type"]): boolean { + return INTENT_TYPE_TO_OPCODE[intentType] !== undefined; +} + export function opcodeToIntentType(opcode: number): Intent["type"] { const intentType = OPCODE_TO_INTENT_TYPE[opcode as BinaryIntentType]; if (intentType === undefined) { diff --git a/tests/core/BinaryCodec.test.ts b/tests/core/BinaryCodec.test.ts index fb5c55e0e..630c6dcbb 100644 --- a/tests/core/BinaryCodec.test.ts +++ b/tests/core/BinaryCodec.test.ts @@ -5,6 +5,7 @@ import { decodeBinaryServerGameplayMessage, encodeBinaryClientGameplayMessage, encodeBinaryServerGameplayMessage, + isBinaryGameplayClientMessage, } from "../../src/core/BinaryCodec"; import { BINARY_PROTOCOL_VERSION } from "../../src/core/BinaryProtocol"; import { @@ -251,6 +252,13 @@ const clientIntentMessages: ClientIntentMessage[] = [ paused: true, }, }, + { + type: "intent", + intent: { + type: "kick_player", + target: "P0000002", + }, + }, ]; describe("BinaryCodec", () => { @@ -283,6 +291,28 @@ describe("BinaryCodec", () => { expect(decoded).toEqual(message); }); + it("only classifies supported intents as binary gameplay messages", () => { + expect( + isBinaryGameplayClientMessage({ + type: "intent", + intent: { + type: "kick_player", + target: "P0000002", + }, + } as ClientIntentMessage), + ).toBe(true); + + expect( + isBinaryGameplayClientMessage({ + type: "intent", + intent: { + type: "update_game_config", + config: {}, + }, + } as ClientIntentMessage), + ).toBe(false); + }); + it("round-trips server turn messages", () => { const message: ServerTurnMessage = { type: "turn", diff --git a/tests/server/GameServerBinaryProtocol.test.ts b/tests/server/GameServerBinaryProtocol.test.ts index 4f34b6e1b..5aace70a0 100644 --- a/tests/server/GameServerBinaryProtocol.test.ts +++ b/tests/server/GameServerBinaryProtocol.test.ts @@ -237,6 +237,65 @@ describe("GameServer binary gameplay protocol", () => { ); }); + it("supports host kicks via binary gameplay intents after start", () => { + const logger = createMockLogger(); + const hostPersistentId = "66666666-6666-4666-8666-666666666666"; + const game = new GameServer( + "TEST0005", + logger as any, + Date.now(), + { + turnIntervalMs: () => 100, + env: () => 0, + } as any, + createGameConfig(), + hostPersistentId, + ); + + const host = createClient("P0000001", hostPersistentId, "Host"); + const target = createClient( + "P0000002", + "77777777-7777-4777-8777-777777777777", + "Target", + ); + + expect(game.joinClient(host.client)).toBe("joined"); + expect(game.joinClient(target.client)).toBe("joined"); + game.start(); + + const startPayload = host.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 kickMessage = encodeBinaryClientGameplayMessage( + { + type: "intent", + intent: { + type: "kick_player", + target: "P0000002", + }, + }, + binaryContext, + ); + + host.ws.emit("message", kickMessage, true); + + expect(target.ws.sent).toEqual( + expect.arrayContaining([ + expect.stringContaining('"error":"kick_reason.lobby_creator"'), + ]), + ); + expect(target.ws.readyState).toBe(3); + expect(game.activeClients.map((client) => client.clientID)).toEqual([ + "P0000001", + ]); + }); + it("kicks malformed binary gameplay messages after start", () => { const logger = createMockLogger(); const game = new GameServer(