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(); + }); +});