Skip to content

Commit de37943

Browse files
authored
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
1 parent d4e0964 commit de37943

File tree

6 files changed

+436
-7
lines changed

6 files changed

+436
-7
lines changed

resources/lang/en.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -796,10 +796,18 @@
796796
"send_troops": "Send Troops",
797797
"send_gold": "Send Gold",
798798
"emotes": "Emojis",
799+
"moderation": "Moderation",
800+
"kick": "Kick player",
801+
"kicked": "Already kicked",
802+
"kick_confirm": "Kick {name}?\n\nThey won't be able to rejoin this game.",
799803
"arc_up": "Upward arc",
800804
"arc_down": "Downward arc",
801805
"flip_rocket_trajectory": "Flip rocket trajectory"
802806
},
807+
"kick_reason": {
808+
"duplicate_session": "Kicked from game (you may have been playing on another tab)",
809+
"lobby_creator": "Kicked by lobby creator"
810+
},
803811
"send_troops_modal": {
804812
"title_with_name": "Send Troops to {name}",
805813
"available_tooltip": "Your current available troops",

src/client/ClientGameRunner.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -770,6 +770,9 @@ function showErrorModal(
770770
return;
771771
}
772772

773+
const translatedError = translateText(error);
774+
const displayError = translatedError === error ? error : translatedError;
775+
773776
const modal = document.createElement("div");
774777
modal.id = "error-modal";
775778

@@ -778,7 +781,7 @@ function showErrorModal(
778781
translateText(heading),
779782
`game id: ${gameID}`,
780783
`client id: ${clientID}`,
781-
`Error: ${error}`,
784+
`Error: ${displayError}`,
782785
message ? `Message: ${message}` : null,
783786
]
784787
.filter(Boolean)
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import { html, LitElement } from "lit";
2+
import { customElement, property } from "lit/decorators.js";
3+
import { EventBus } from "../../../core/EventBus";
4+
import { PlayerType } from "../../../core/game/Game";
5+
import { PlayerView } from "../../../core/game/GameView";
6+
import { actionButton } from "../../components/ui/ActionButton";
7+
import { SendKickPlayerIntentEvent } from "../../Transport";
8+
import { translateText } from "../../Utils";
9+
import kickIcon from "/images/ExitIconWhite.svg?url";
10+
import shieldIcon from "/images/ShieldIconWhite.svg?url";
11+
12+
@customElement("player-moderation-modal")
13+
export class PlayerModerationModal extends LitElement {
14+
@property({ attribute: false }) eventBus: EventBus | null = null;
15+
@property({ attribute: false }) myPlayer: PlayerView | null = null;
16+
@property({ attribute: false }) target: PlayerView | null = null;
17+
18+
@property({ type: Boolean }) open: boolean = false;
19+
@property({ type: Boolean }) alreadyKicked: boolean = false;
20+
21+
createRenderRoot() {
22+
return this;
23+
}
24+
25+
updated(changed: Map<string, unknown>) {
26+
if (changed.has("open") && this.open) {
27+
queueMicrotask(() =>
28+
(this.querySelector('[role="dialog"]') as HTMLElement | null)?.focus(),
29+
);
30+
}
31+
}
32+
33+
private closeModal() {
34+
this.dispatchEvent(new CustomEvent("close"));
35+
}
36+
37+
private handleKeydown = (e: KeyboardEvent) => {
38+
if (e.key === "Escape") {
39+
e.preventDefault();
40+
this.closeModal();
41+
}
42+
};
43+
44+
private canKick(my: PlayerView, other: PlayerView): boolean {
45+
return (
46+
my.isLobbyCreator() &&
47+
other !== my &&
48+
other.type() === PlayerType.Human &&
49+
!!other.clientID()
50+
);
51+
}
52+
53+
private handleKickClick = (e: MouseEvent) => {
54+
e.stopPropagation();
55+
56+
const my = this.myPlayer;
57+
const other = this.target;
58+
const eventBus = this.eventBus;
59+
60+
if (!my || !other) return;
61+
if (!this.canKick(my, other) || this.alreadyKicked) return;
62+
if (!eventBus) return;
63+
64+
const targetClientID = other.clientID();
65+
if (!targetClientID || targetClientID.length === 0) return;
66+
67+
const confirmed = confirm(
68+
translateText("player_panel.kick_confirm", { name: other.name() }),
69+
);
70+
if (!confirmed) return;
71+
72+
eventBus.emit(new SendKickPlayerIntentEvent(targetClientID));
73+
this.dispatchEvent(
74+
new CustomEvent("kicked", { detail: { playerId: String(other.id()) } }),
75+
);
76+
this.closeModal();
77+
};
78+
79+
render() {
80+
if (!this.open) return html``;
81+
82+
const my = this.myPlayer;
83+
const other = this.target;
84+
if (!my || !other) return html``;
85+
86+
const canKick = this.canKick(my, other);
87+
const alreadyKicked = this.alreadyKicked;
88+
89+
const moderationTitle = translateText("player_panel.moderation");
90+
const kickTitle = alreadyKicked
91+
? translateText("player_panel.kicked")
92+
: translateText("player_panel.kick");
93+
94+
return html`
95+
<div class="absolute inset-0 z-1200 flex items-center justify-center p-4">
96+
<div
97+
class="absolute inset-0 bg-black/60 rounded-2xl"
98+
@click=${() => this.closeModal()}
99+
></div>
100+
101+
<div
102+
role="dialog"
103+
aria-modal="true"
104+
aria-labelledby="moderation-title"
105+
class="relative z-10 w-full max-w-120 focus:outline-hidden"
106+
tabindex="0"
107+
@keydown=${this.handleKeydown}
108+
>
109+
<div
110+
class="rounded-2xl bg-zinc-900 p-5 shadow-2xl ring-1 ring-zinc-800 max-h-[90vh] text-zinc-200"
111+
@click=${(e: MouseEvent) => e.stopPropagation()}
112+
>
113+
<div class="mb-3 flex items-center justify-between relative">
114+
<div class="flex items-center gap-2">
115+
<img
116+
src=${shieldIcon}
117+
alt=""
118+
aria-hidden="true"
119+
class="h-5 w-5"
120+
/>
121+
<h2
122+
id="moderation-title"
123+
class="text-lg font-semibold tracking-tight text-zinc-100"
124+
>
125+
${moderationTitle}
126+
</h2>
127+
</div>
128+
129+
<button
130+
type="button"
131+
@click=${() => this.closeModal()}
132+
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"
133+
aria-label=${translateText("common.close")}
134+
title=${translateText("common.close")}
135+
>
136+
137+
</button>
138+
</div>
139+
140+
<div
141+
class="mb-4 rounded-xl border border-white/10 bg-white/5 px-3 py-2"
142+
>
143+
<div
144+
class="text-sm font-semibold text-zinc-100 truncate"
145+
title=${other.name()}
146+
>
147+
${other.name()}
148+
</div>
149+
</div>
150+
151+
<div class="grid auto-cols-fr grid-flow-col gap-1">
152+
${actionButton({
153+
onClick: this.handleKickClick,
154+
icon: kickIcon,
155+
iconAlt: "Kick",
156+
title: kickTitle,
157+
label: kickTitle,
158+
type: "red",
159+
disabled: alreadyKicked || !canKick,
160+
})}
161+
</div>
162+
</div>
163+
</div>
164+
</div>
165+
`;
166+
}
167+
}

src/client/graphics/layers/PlayerPanel.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,14 @@ import { UIState } from "../UIState";
3737
import { ChatModal } from "./ChatModal";
3838
import { EmojiTable } from "./EmojiTable";
3939
import { Layer } from "./Layer";
40+
import "./PlayerModerationModal";
4041
import "./SendResourceModal";
4142
import allianceIcon from "/images/AllianceIconWhite.svg?url";
4243
import chatIcon from "/images/ChatIconWhite.svg?url";
4344
import donateGoldIcon from "/images/DonateGoldIconWhite.svg?url";
4445
import donateTroopIcon from "/images/DonateTroopIconWhite.svg?url";
4546
import emojiIcon from "/images/EmojiIconWhite.svg?url";
47+
import shieldIcon from "/images/ShieldIconWhite.svg?url";
4648
import stopTradingIcon from "/images/StopIconWhite.png?url";
4749
import targetIcon from "/images/TargetIconWhite.svg?url";
4850
import startTradingIcon from "/images/TradingIconWhite.png?url";
@@ -59,6 +61,7 @@ export class PlayerPanel extends LitElement implements Layer {
5961
private actions: PlayerActions | null = null;
6062
private tile: TileRef | null = null;
6163
private _profileForPlayerId: number | null = null;
64+
private kickedPlayerIDs = new Set<string>();
6265

6366
@state() private sendTarget: PlayerView | null = null;
6467
@state() private sendMode: "troops" | "gold" | "none" = "none";
@@ -67,6 +70,7 @@ export class PlayerPanel extends LitElement implements Layer {
6770
@state() private allianceExpirySeconds: number | null = null;
6871
@state() private otherProfile: PlayerProfile | null = null;
6972
@state() private suppressNextHide: boolean = false;
73+
@state() private moderationTarget: PlayerView | null = null;
7074

7175
private ctModal: ChatModal;
7276

@@ -142,6 +146,7 @@ export class PlayerPanel extends LitElement implements Layer {
142146
public show(actions: PlayerActions, tile: TileRef) {
143147
this.actions = actions;
144148
this.tile = tile;
149+
this.moderationTarget = null;
145150
this.isVisible = true;
146151
this.requestUpdate();
147152
}
@@ -156,6 +161,7 @@ export class PlayerPanel extends LitElement implements Layer {
156161
this.tile = tile;
157162
this.sendTarget = target;
158163
this.sendMode = "gold";
164+
this.moderationTarget = null;
159165
this.isVisible = true;
160166
this.requestUpdate();
161167
}
@@ -164,6 +170,7 @@ export class PlayerPanel extends LitElement implements Layer {
164170
this.isVisible = false;
165171
this.sendMode = "none";
166172
this.sendTarget = null;
173+
this.moderationTarget = null;
167174
this.requestUpdate();
168175
}
169176

@@ -305,6 +312,23 @@ export class PlayerPanel extends LitElement implements Layer {
305312
this.hide();
306313
}
307314

315+
private openModeration(e: MouseEvent, other: PlayerView) {
316+
e.stopPropagation();
317+
this.suppressNextHide = true;
318+
this.moderationTarget = other;
319+
}
320+
321+
private closeModeration = () => {
322+
this.moderationTarget = null;
323+
};
324+
325+
private handleModerationKicked = (e: CustomEvent<{ playerId?: string }>) => {
326+
const playerId = e.detail?.playerId;
327+
if (playerId) this.kickedPlayerIDs.add(String(playerId));
328+
this.closeModeration();
329+
this.hide();
330+
};
331+
308332
private handleToggleRocketDirection(e: Event) {
309333
e.stopPropagation();
310334
const next = !this.uiState.rocketDirectionUp;
@@ -419,6 +443,25 @@ export class PlayerPanel extends LitElement implements Layer {
419443
`;
420444
}
421445

446+
private renderModeration(my: PlayerView, other: PlayerView) {
447+
if (!my.isLobbyCreator()) return html``;
448+
const moderationTitle = translateText("player_panel.moderation");
449+
450+
return html`
451+
<ui-divider></ui-divider>
452+
<div class="grid auto-cols-fr grid-flow-col gap-1">
453+
${actionButton({
454+
onClick: (e: MouseEvent) => this.openModeration(e, other),
455+
icon: shieldIcon,
456+
iconAlt: "Moderation",
457+
title: moderationTitle,
458+
label: moderationTitle,
459+
type: "red",
460+
})}
461+
</div>
462+
`;
463+
}
464+
422465
private renderRelationPillIfNation(other: PlayerView, my: PlayerView) {
423466
if (other.type() !== PlayerType.Nation) return html``;
424467
if (other.isTraitor()) return html``;
@@ -804,6 +847,7 @@ export class PlayerPanel extends LitElement implements Layer {
804847
})}
805848
</div>`
806849
: ""}
850+
${this.renderModeration(my, other)}
807851
</div>
808852
`;
809853
}
@@ -914,6 +958,21 @@ export class PlayerPanel extends LitElement implements Layer {
914958
></send-resource-modal>
915959
`
916960
: ""}
961+
${this.moderationTarget
962+
? html`
963+
<player-moderation-modal
964+
.open=${true}
965+
.myPlayer=${my}
966+
.target=${this.moderationTarget}
967+
.eventBus=${this.eventBus}
968+
.alreadyKicked=${this.kickedPlayerIDs.has(
969+
String(this.moderationTarget.id()),
970+
)}
971+
@close=${this.closeModeration}
972+
@kicked=${this.handleModerationKicked}
973+
></player-moderation-modal>
974+
`
975+
: ""}
917976
918977
<ui-divider></ui-divider>
919978

0 commit comments

Comments
 (0)