From de3794313db5bbef8bb0f697c1f011f4629cff3e Mon Sep 17 00:00:00 2001 From: Mitchell Zinck Date: Sat, 24 Jan 2026 23:55:58 -0500 Subject: [PATCH] feat: Kick player in game (#2969) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If this PR fixes an issue, link it below. If not, delete these two lines. Resolves #2686 ## Description: - Implemented feature for lobby creator to kick players in game. - Added new moderation option for lobby creator, with a kick player option if they aren't the creator, a bot, and exist in game. - Includes a confirm kick option, and keeps track of kicked players so that the kick option changes to "Already Kicked" if the kicked player panel is opened again on the kicked player. Screenshot order: 1) Open player panel 2) Click on moderation 3) Click on kick player and confirm kick 4) Player is kicked, open same player panel again and observe change in kick status 5) Receiving player kick message Screenshot 2026-01-20 at 12 33
55 PM Screenshot 2026-01-20 at 11 58
58 AM Screenshot 2026-01-20 at 12 31
46 PM Screenshot 2026-01-20 at 11 57
58 AM Screenshot 2026-01-20 at 11 57
39 AM ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: mitchfz --- resources/lang/en.json | 8 + src/client/ClientGameRunner.ts | 5 +- .../graphics/layers/PlayerModerationModal.ts | 167 +++++++++++++++++ src/client/graphics/layers/PlayerPanel.ts | 59 ++++++ src/server/GameServer.ts | 34 +++- .../graphics/layers/PlayerPanelKick.test.ts | 170 ++++++++++++++++++ 6 files changed, 436 insertions(+), 7 deletions(-) create mode 100644 src/client/graphics/layers/PlayerModerationModal.ts create mode 100644 tests/client/graphics/layers/PlayerPanelKick.test.ts diff --git a/resources/lang/en.json b/resources/lang/en.json index 2ae02389d..a9663b2b0 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -796,10 +796,18 @@ "send_troops": "Send Troops", "send_gold": "Send Gold", "emotes": "Emojis", + "moderation": "Moderation", + "kick": "Kick player", + "kicked": "Already kicked", + "kick_confirm": "Kick {name}?\n\nThey won't be able to rejoin this game.", "arc_up": "Upward arc", "arc_down": "Downward arc", "flip_rocket_trajectory": "Flip rocket trajectory" }, + "kick_reason": { + "duplicate_session": "Kicked from game (you may have been playing on another tab)", + "lobby_creator": "Kicked by lobby creator" + }, "send_troops_modal": { "title_with_name": "Send Troops to {name}", "available_tooltip": "Your current available troops", diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index bd377d35d..b71fb7cd6 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -770,6 +770,9 @@ function showErrorModal( return; } + const translatedError = translateText(error); + const displayError = translatedError === error ? error : translatedError; + const modal = document.createElement("div"); modal.id = "error-modal"; @@ -778,7 +781,7 @@ function showErrorModal( translateText(heading), `game id: ${gameID}`, `client id: ${clientID}`, - `Error: ${error}`, + `Error: ${displayError}`, message ? `Message: ${message}` : null, ] .filter(Boolean) diff --git a/src/client/graphics/layers/PlayerModerationModal.ts b/src/client/graphics/layers/PlayerModerationModal.ts new file mode 100644 index 000000000..c51f9efc1 --- /dev/null +++ b/src/client/graphics/layers/PlayerModerationModal.ts @@ -0,0 +1,167 @@ +import { html, LitElement } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { EventBus } from "../../../core/EventBus"; +import { PlayerType } from "../../../core/game/Game"; +import { PlayerView } from "../../../core/game/GameView"; +import { actionButton } from "../../components/ui/ActionButton"; +import { SendKickPlayerIntentEvent } from "../../Transport"; +import { translateText } from "../../Utils"; +import kickIcon from "/images/ExitIconWhite.svg?url"; +import shieldIcon from "/images/ShieldIconWhite.svg?url"; + +@customElement("player-moderation-modal") +export class PlayerModerationModal extends LitElement { + @property({ attribute: false }) eventBus: EventBus | null = null; + @property({ attribute: false }) myPlayer: PlayerView | null = null; + @property({ attribute: false }) target: PlayerView | null = null; + + @property({ type: Boolean }) open: boolean = false; + @property({ type: Boolean }) alreadyKicked: boolean = false; + + createRenderRoot() { + return this; + } + + updated(changed: Map) { + if (changed.has("open") && this.open) { + queueMicrotask(() => + (this.querySelector('[role="dialog"]') as HTMLElement | null)?.focus(), + ); + } + } + + private closeModal() { + this.dispatchEvent(new CustomEvent("close")); + } + + private handleKeydown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + this.closeModal(); + } + }; + + private canKick(my: PlayerView, other: PlayerView): boolean { + return ( + my.isLobbyCreator() && + other !== my && + other.type() === PlayerType.Human && + !!other.clientID() + ); + } + + private handleKickClick = (e: MouseEvent) => { + e.stopPropagation(); + + const my = this.myPlayer; + const other = this.target; + const eventBus = this.eventBus; + + if (!my || !other) return; + if (!this.canKick(my, other) || this.alreadyKicked) return; + if (!eventBus) return; + + const targetClientID = other.clientID(); + if (!targetClientID || targetClientID.length === 0) return; + + const confirmed = confirm( + translateText("player_panel.kick_confirm", { name: other.name() }), + ); + if (!confirmed) return; + + eventBus.emit(new SendKickPlayerIntentEvent(targetClientID)); + this.dispatchEvent( + new CustomEvent("kicked", { detail: { playerId: String(other.id()) } }), + ); + this.closeModal(); + }; + + render() { + if (!this.open) return html``; + + const my = this.myPlayer; + const other = this.target; + if (!my || !other) return html``; + + const canKick = this.canKick(my, other); + const alreadyKicked = this.alreadyKicked; + + const moderationTitle = translateText("player_panel.moderation"); + const kickTitle = alreadyKicked + ? translateText("player_panel.kicked") + : translateText("player_panel.kick"); + + return html` +
+
this.closeModal()} + >
+ + +
+ `; + } +} diff --git a/src/client/graphics/layers/PlayerPanel.ts b/src/client/graphics/layers/PlayerPanel.ts index 20883f88e..674b83e15 100644 --- a/src/client/graphics/layers/PlayerPanel.ts +++ b/src/client/graphics/layers/PlayerPanel.ts @@ -37,12 +37,14 @@ import { UIState } from "../UIState"; import { ChatModal } from "./ChatModal"; import { EmojiTable } from "./EmojiTable"; import { Layer } from "./Layer"; +import "./PlayerModerationModal"; import "./SendResourceModal"; import allianceIcon from "/images/AllianceIconWhite.svg?url"; import chatIcon from "/images/ChatIconWhite.svg?url"; import donateGoldIcon from "/images/DonateGoldIconWhite.svg?url"; import donateTroopIcon from "/images/DonateTroopIconWhite.svg?url"; import emojiIcon from "/images/EmojiIconWhite.svg?url"; +import shieldIcon from "/images/ShieldIconWhite.svg?url"; import stopTradingIcon from "/images/StopIconWhite.png?url"; import targetIcon from "/images/TargetIconWhite.svg?url"; import startTradingIcon from "/images/TradingIconWhite.png?url"; @@ -59,6 +61,7 @@ export class PlayerPanel extends LitElement implements Layer { private actions: PlayerActions | null = null; private tile: TileRef | null = null; private _profileForPlayerId: number | null = null; + private kickedPlayerIDs = new Set(); @state() private sendTarget: PlayerView | null = null; @state() private sendMode: "troops" | "gold" | "none" = "none"; @@ -67,6 +70,7 @@ export class PlayerPanel extends LitElement implements Layer { @state() private allianceExpirySeconds: number | null = null; @state() private otherProfile: PlayerProfile | null = null; @state() private suppressNextHide: boolean = false; + @state() private moderationTarget: PlayerView | null = null; private ctModal: ChatModal; @@ -142,6 +146,7 @@ export class PlayerPanel extends LitElement implements Layer { public show(actions: PlayerActions, tile: TileRef) { this.actions = actions; this.tile = tile; + this.moderationTarget = null; this.isVisible = true; this.requestUpdate(); } @@ -156,6 +161,7 @@ export class PlayerPanel extends LitElement implements Layer { this.tile = tile; this.sendTarget = target; this.sendMode = "gold"; + this.moderationTarget = null; this.isVisible = true; this.requestUpdate(); } @@ -164,6 +170,7 @@ export class PlayerPanel extends LitElement implements Layer { this.isVisible = false; this.sendMode = "none"; this.sendTarget = null; + this.moderationTarget = null; this.requestUpdate(); } @@ -305,6 +312,23 @@ export class PlayerPanel extends LitElement implements Layer { this.hide(); } + private openModeration(e: MouseEvent, other: PlayerView) { + e.stopPropagation(); + this.suppressNextHide = true; + this.moderationTarget = other; + } + + private closeModeration = () => { + this.moderationTarget = null; + }; + + private handleModerationKicked = (e: CustomEvent<{ playerId?: string }>) => { + const playerId = e.detail?.playerId; + if (playerId) this.kickedPlayerIDs.add(String(playerId)); + this.closeModeration(); + this.hide(); + }; + private handleToggleRocketDirection(e: Event) { e.stopPropagation(); const next = !this.uiState.rocketDirectionUp; @@ -419,6 +443,25 @@ export class PlayerPanel extends LitElement implements Layer { `; } + private renderModeration(my: PlayerView, other: PlayerView) { + if (!my.isLobbyCreator()) return html``; + const moderationTitle = translateText("player_panel.moderation"); + + return html` + +
+ ${actionButton({ + onClick: (e: MouseEvent) => this.openModeration(e, other), + icon: shieldIcon, + iconAlt: "Moderation", + title: moderationTitle, + label: moderationTitle, + type: "red", + })} +
+ `; + } + private renderRelationPillIfNation(other: PlayerView, my: PlayerView) { if (other.type() !== PlayerType.Nation) return html``; if (other.isTraitor()) return html``; @@ -804,6 +847,7 @@ export class PlayerPanel extends LitElement implements Layer { })} ` : ""} + ${this.renderModeration(my, other)} `; } @@ -914,6 +958,21 @@ export class PlayerPanel extends LitElement implements Layer { > ` : ""} + ${this.moderationTarget + ? html` + + ` + : ""} diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 22bac20d6..556f7bf97 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -31,6 +31,9 @@ export enum GamePhase { Finished = "FINISHED", } +const KICK_REASON_DUPLICATE_SESSION = "kick_reason.duplicate_session"; +const KICK_REASON_LOBBY_CREATOR = "kick_reason.lobby_creator"; + export class GameServer { private sentDesyncMessageClients = new Set(); @@ -219,7 +222,7 @@ export class GameServer { }); // Kick the existing client instead of the new one, because this was causing issues when // a client wanted to replay the game afterwards. - this.kickClient(conflicting.clientID); + this.kickClient(conflicting.clientID, KICK_REASON_DUPLICATE_SESSION); } } @@ -356,7 +359,10 @@ export class GameServer { kickMethod: "websocket", }); - this.kickClient(clientMsg.intent.target); + this.kickClient( + clientMsg.intent.target, + KICK_REASON_LOBBY_CREATOR, + ); return; } case "update_game_config": { @@ -776,33 +782,49 @@ export class GameServer { return this.gameConfig.gameType === GameType.Public; } - public kickClient(clientID: ClientID): void { + public kickClient( + clientID: ClientID, + reasonKey: string = KICK_REASON_DUPLICATE_SESSION, + ): void { if (this.kickedClients.has(clientID)) { this.log.warn(`cannot kick client, already kicked`, { clientID, + reasonKey, }); return; } + + if (!this.allClients.has(clientID)) { + this.log.warn(`cannot kick client, not found in game`, { + clientID, + reasonKey, + }); + return; + } + + this.kickedClients.add(clientID); + const client = this.activeClients.find((c) => c.clientID === clientID); if (client) { this.log.info("Kicking client from game", { clientID: client.clientID, persistentID: client.persistentID, + reasonKey, }); client.ws.send( JSON.stringify({ type: "error", - error: "Kicked from game (you may have been playing on another tab)", + error: reasonKey, } satisfies ServerErrorMessage), ); - client.ws.close(1000, "Kicked from game"); + client.ws.close(1000, reasonKey); this.activeClients = this.activeClients.filter( (c) => c.clientID !== clientID, ); - this.kickedClients.add(clientID); } else { this.log.warn(`cannot kick client, not found in game`, { clientID, + reasonKey, }); } } diff --git a/tests/client/graphics/layers/PlayerPanelKick.test.ts b/tests/client/graphics/layers/PlayerPanelKick.test.ts new file mode 100644 index 000000000..ea87feed3 --- /dev/null +++ b/tests/client/graphics/layers/PlayerPanelKick.test.ts @@ -0,0 +1,170 @@ +vi.mock("lit", () => ({ + html: (strings: TemplateStringsArray, ...values: unknown[]) => ({ + strings, + values, + }), + LitElement: class extends EventTarget { + requestUpdate() {} + }, +})); + +vi.mock("lit/decorators.js", () => ({ + customElement: () => (clazz: unknown) => clazz, + state: () => () => {}, + property: () => () => {}, + query: () => () => {}, +})); + +vi.mock("../../../../src/client/Utils", () => ({ + translateText: vi.fn((key: string) => key), + renderDuration: vi.fn(), + renderNumber: vi.fn(), + renderTroops: vi.fn(), +})); + +vi.mock("../../../../src/client/components/ui/ActionButton", () => ({ + actionButton: vi.fn((props: unknown) => props), +})); + +import { actionButton } from "../../../../src/client/components/ui/ActionButton"; +import { PlayerModerationModal } from "../../../../src/client/graphics/layers/PlayerModerationModal"; +import { PlayerPanel } from "../../../../src/client/graphics/layers/PlayerPanel"; +import { SendKickPlayerIntentEvent } from "../../../../src/client/Transport"; +import { PlayerType } from "../../../../src/core/game/Game"; +import { PlayerView } from "../../../../src/core/game/GameView"; + +describe("PlayerPanel - kick player moderation", () => { + let panel: PlayerPanel; + const originalConfirm = globalThis.confirm; + + beforeEach(() => { + panel = new PlayerPanel(); + (panel as any).requestUpdate = vi.fn(); + (panel as any).isVisible = true; + }); + + afterEach(() => { + vi.clearAllMocks(); + globalThis.confirm = originalConfirm; + }); + + test("renders moderation action only when allowed or already kicked", () => { + const my = { isLobbyCreator: () => true } as unknown as PlayerView; + const other = { + id: () => 2, + name: () => "Other", + type: () => PlayerType.Human, + clientID: () => "client-2", + } as unknown as PlayerView; + + (actionButton as unknown as ReturnType).mockClear(); + (panel as any).renderModeration(my, other); + expect(actionButton).toHaveBeenCalledTimes(1); + expect( + (actionButton as unknown as ReturnType).mock.calls[0][0], + ).toMatchObject({ + label: "player_panel.moderation", + title: "player_panel.moderation", + type: "red", + }); + + (actionButton as unknown as ReturnType).mockClear(); + (panel as any).kickedPlayerIDs.add("2"); + (panel as any).renderModeration(my, other); + expect(actionButton).toHaveBeenCalledTimes(1); + + const notCreator = { isLobbyCreator: () => false } as unknown as PlayerView; + (actionButton as unknown as ReturnType).mockClear(); + (panel as any).kickedPlayerIDs.clear(); + (panel as any).renderModeration(notCreator, other); + expect(actionButton).not.toHaveBeenCalled(); + }); + + test("opens moderation modal and hides after a kick", () => { + const other = { + id: () => 2, + name: () => "Other", + type: () => PlayerType.Human, + clientID: () => "client-2", + } as unknown as PlayerView; + + (panel as any).openModeration({ stopPropagation: vi.fn() }, other); + expect((panel as any).moderationTarget).toBe(other); + expect((panel as any).suppressNextHide).toBe(true); + + (panel as any).handleModerationKicked( + new CustomEvent("kicked", { detail: { playerId: "2" } }), + ); + + expect((panel as any).kickedPlayerIDs.has("2")).toBe(true); + expect((panel as any).moderationTarget).toBe(null); + expect((panel as any).isVisible).toBe(false); + }); +}); + +describe("PlayerModerationModal - kick confirmation", () => { + const originalConfirm = globalThis.confirm; + + afterEach(() => { + vi.clearAllMocks(); + globalThis.confirm = originalConfirm; + }); + + test("emits SendKickPlayerIntentEvent and dispatches kicked when confirmed", () => { + (globalThis as any).confirm = vi.fn(() => true); + + const modal = new PlayerModerationModal(); + const eventBus = { emit: vi.fn() }; + const my = { isLobbyCreator: () => true } as unknown as PlayerView; + const other = { + id: () => 2, + name: () => "Other", + type: () => PlayerType.Human, + clientID: () => "client-2", + } as unknown as PlayerView; + + modal.eventBus = eventBus as any; + modal.myPlayer = my; + modal.target = other; + + const kickedListener = vi.fn(); + modal.addEventListener("kicked", kickedListener as any); + + (modal as any).handleKickClick({ stopPropagation: vi.fn() }); + + expect(eventBus.emit).toHaveBeenCalledTimes(1); + const event = eventBus.emit.mock.calls[0][0] as SendKickPlayerIntentEvent; + expect(event).toBeInstanceOf(SendKickPlayerIntentEvent); + expect(event.target).toBe("client-2"); + + expect(kickedListener).toHaveBeenCalledTimes(1); + const kickedEvent = kickedListener.mock.calls[0][0] as CustomEvent; + expect(kickedEvent.detail).toEqual({ playerId: "2" }); + }); + + test("does not emit when confirmation is cancelled", () => { + (globalThis as any).confirm = vi.fn(() => false); + + const modal = new PlayerModerationModal(); + const eventBus = { emit: vi.fn() }; + const my = { isLobbyCreator: () => true } as unknown as PlayerView; + const other = { + id: () => 2, + name: () => "Other", + type: () => PlayerType.Human, + clientID: () => "client-2", + } as unknown as PlayerView; + + modal.eventBus = eventBus as any; + modal.myPlayer = my; + modal.target = other; + + const kickedListener = vi.fn(); + modal.addEventListener("kicked", kickedListener as any); + + (modal as any).handleKickClick({ stopPropagation: vi.fn() }); + + expect(eventBus.emit).not.toHaveBeenCalled(); + expect(kickedListener).not.toHaveBeenCalled(); + }); +});