diff --git a/src/client/Transport.ts b/src/client/Transport.ts index c1307af69..e5311e165 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -640,7 +640,7 @@ export class Transport { private onSendKickPlayerIntent(event: SendKickPlayerIntentEvent) { this.sendIntent({ type: "kick_player", - target: event.target, + targetClientID: event.target, }); } diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index eeccfd94c..4884103df 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -477,7 +477,11 @@ export const MarkDisconnectedIntentSchema = z.object({ export const KickPlayerIntentSchema = z.object({ type: z.literal("kick_player"), - target: ID, + // Either a live clientID (lobby / in-game kick) OR an account publicID, for + // callers that identify a player by account rather than per-session clientID; + // the server resolves the publicID to the live clientID. Exactly one is set. + targetClientID: ID.optional(), + targetPublicID: ID.optional(), }); export const TogglePauseIntentSchema = z.object({ diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 43b7b5d55..8ad31145c 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -265,7 +265,19 @@ export class GameServer { error: "only the lobby creator or an admin can kick players", }; } - if (stamped.clientID === stamped.target) { + // Resolve the target to a live clientID: an explicit clientID, or an + // account publicId matched against the connected clients (for callers + // that know the account but not the per-session clientID). + let target = stamped.targetClientID; + if (target === undefined && stamped.targetPublicID !== undefined) { + target = this.activeClients.find( + (c) => c.publicId === stamped.targetPublicID, + )?.clientID; + } + if (target === undefined) { + return { status: 404, error: "no matching player to kick" }; + } + if (stamped.clientID === target) { return { status: 400, error: "cannot kick yourself" }; } const reason = @@ -274,12 +286,12 @@ export class GameServer { : KICK_REASON_LOBBY_CREATOR; this.log.info("player kicked", { kicker: stamped.clientID, - target: stamped.target, + target, isAdmin: actor.isAdmin, isAdminBot: actor.isAdminBot, gameID: this.id, }); - this.kickClient(stamped.target, reason); + this.kickClient(target, reason); return { status: 200 }; } diff --git a/tests/server/AdminBotIntent.test.ts b/tests/server/AdminBotIntent.test.ts index 421955feb..232d18a1a 100644 --- a/tests/server/AdminBotIntent.test.ts +++ b/tests/server/AdminBotIntent.test.ts @@ -115,7 +115,7 @@ describe("GameServer.handleIntent (admin bot)", () => { const spy = vi.spyOn(game, "kickClient"); const result = apply(game, { type: "kick_player", - target: "abcdABCD", + targetClientID: "abcdABCD", } as any); expect(result.status).toBe(200); expect(spy).toHaveBeenCalledWith("abcdABCD", expect.any(String)); @@ -126,10 +126,35 @@ describe("GameServer.handleIntent (admin bot)", () => { expect( apply(game, { type: "kick_player", - target: "abcdABCD", + targetClientID: "abcdABCD", } as any).status, ).toBe(403); }); + + it("resolves a publicID target to the connected client's clientID", () => { + const game = makeGame(); + (game as any).activeClients.push({ + clientID: "liveCID1", + publicId: "pubABCD1", + }); + const spy = vi.spyOn(game, "kickClient"); + const result = apply(game, { + type: "kick_player", + targetPublicID: "pubABCD1", + } as any); + expect(result.status).toBe(200); + expect(spy).toHaveBeenCalledWith("liveCID1", expect.any(String)); + }); + + it("404s when no connected client matches the publicID", () => { + const game = makeGame(); + expect( + apply(game, { + type: "kick_player", + targetPublicID: "nobodyXX", + } as any).status, + ).toBe(404); + }); }); describe("toggle_pause", () => { diff --git a/tests/server/KickPlayerAuthorization.test.ts b/tests/server/KickPlayerAuthorization.test.ts index 9dc04a1c0..fcea94d9d 100644 --- a/tests/server/KickPlayerAuthorization.test.ts +++ b/tests/server/KickPlayerAuthorization.test.ts @@ -93,7 +93,7 @@ describe("GameServer - kick_player authorization", () => { "message", JSON.stringify({ type: "intent", - intent: { type: "kick_player", target }, + intent: { type: "kick_player", targetClientID: target }, }), ); }