feat: Kick player in game (#2969)

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

<img width="1470" height="776" alt="Screenshot 2026-01-20 at 12 33
55 PM"
src="https://github.com/user-attachments/assets/7c47b5a2-a0f8-4e92-833c-7b9732f751a8"
/>
<img width="1470" height="776" alt="Screenshot 2026-01-20 at 11 58
58 AM"
src="https://github.com/user-attachments/assets/3aa026af-9a42-4512-91b8-916f146849a6"
/>
<img width="1470" height="776" alt="Screenshot 2026-01-20 at 12 31
46 PM"
src="https://github.com/user-attachments/assets/5e1d271b-bf32-4335-8eb1-bcdf84aba8ce"
/>
<img width="1470" height="776" alt="Screenshot 2026-01-20 at 11 57
58 AM"
src="https://github.com/user-attachments/assets/7cbd5ea6-bcb6-4a35-a003-ea0add936925"
/>
<img width="1470" height="776" alt="Screenshot 2026-01-20 at 11 57
39 AM"
src="https://github.com/user-attachments/assets/4309b3e3-2fe6-48dd-8e0c-55036e567461"
/>



## 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
This commit is contained in:
Mitchell Zinck
2026-01-24 23:55:58 -05:00
committed by GitHub
parent d4e09644b0
commit de3794313d
6 changed files with 436 additions and 7 deletions
+4 -1
View File
@@ -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)
@@ -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<string, unknown>) {
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`
<div class="absolute inset-0 z-1200 flex items-center justify-center p-4">
<div
class="absolute inset-0 bg-black/60 rounded-2xl"
@click=${() => this.closeModal()}
></div>
<div
role="dialog"
aria-modal="true"
aria-labelledby="moderation-title"
class="relative z-10 w-full max-w-120 focus:outline-hidden"
tabindex="0"
@keydown=${this.handleKeydown}
>
<div
class="rounded-2xl bg-zinc-900 p-5 shadow-2xl ring-1 ring-zinc-800 max-h-[90vh] text-zinc-200"
@click=${(e: MouseEvent) => e.stopPropagation()}
>
<div class="mb-3 flex items-center justify-between relative">
<div class="flex items-center gap-2">
<img
src=${shieldIcon}
alt=""
aria-hidden="true"
class="h-5 w-5"
/>
<h2
id="moderation-title"
class="text-lg font-semibold tracking-tight text-zinc-100"
>
${moderationTitle}
</h2>
</div>
<button
type="button"
@click=${() => this.closeModal()}
class="absolute -top-3 -right-3 flex h-7 w-7 items-center justify-center rounded-full bg-zinc-700 text-white shadow-sm hover:bg-red-500 transition-colors focus-visible:ring-2 focus-visible:ring-white/30 focus:outline-hidden"
aria-label=${translateText("common.close")}
title=${translateText("common.close")}
>
</button>
</div>
<div
class="mb-4 rounded-xl border border-white/10 bg-white/5 px-3 py-2"
>
<div
class="text-sm font-semibold text-zinc-100 truncate"
title=${other.name()}
>
${other.name()}
</div>
</div>
<div class="grid auto-cols-fr grid-flow-col gap-1">
${actionButton({
onClick: this.handleKickClick,
icon: kickIcon,
iconAlt: "Kick",
title: kickTitle,
label: kickTitle,
type: "red",
disabled: alreadyKicked || !canKick,
})}
</div>
</div>
</div>
</div>
`;
}
}
+59
View File
@@ -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<string>();
@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`
<ui-divider></ui-divider>
<div class="grid auto-cols-fr grid-flow-col gap-1">
${actionButton({
onClick: (e: MouseEvent) => this.openModeration(e, other),
icon: shieldIcon,
iconAlt: "Moderation",
title: moderationTitle,
label: moderationTitle,
type: "red",
})}
</div>
`;
}
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 {
})}
</div>`
: ""}
${this.renderModeration(my, other)}
</div>
`;
}
@@ -914,6 +958,21 @@ export class PlayerPanel extends LitElement implements Layer {
></send-resource-modal>
`
: ""}
${this.moderationTarget
? html`
<player-moderation-modal
.open=${true}
.myPlayer=${my}
.target=${this.moderationTarget}
.eventBus=${this.eventBus}
.alreadyKicked=${this.kickedPlayerIDs.has(
String(this.moderationTarget.id()),
)}
@close=${this.closeModeration}
@kicked=${this.handleModerationKicked}
></player-moderation-modal>
`
: ""}
<ui-divider></ui-divider>
+28 -6
View File
@@ -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<ClientID>();
@@ -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,
});
}
}