Private Lobbies: Add kick player functionality (#1436)

## Description:
Added player management features so lobby hosts can kick players from
private games. This includes both UI changes and backend work.

### What's new:
- Hosts can now kick players from private lobbies with a simple button
- Added host badges and remove buttons to the UI
- Made sure only hosts can kick people, and hosts can't kick themselves

### How it works:
- When someone creates a private game, they automatically become the
host
- Kicking happens through WebSocket "kick-player" events
- Server checks that you're actually the host before letting you kick
anyone

<img width="1291" height="871" alt="Screenshot 2025-07-15 002114"
src="https://github.com/user-attachments/assets/ea575f83-a0f4-45d1-9cfe-7521d373f3d5"
/>



### Known Issues:
- Kicked player gets general message (same when kicked for multi tab)

### Other Issues:
- Host abandoment still existent (host clicks on x; or is closing tab)

## 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
- [x] I have read and accepted the CLA agreement (only required once).

## Please put your Discord username so you can be contacted if a bug or
regression is found:

[UN]nvm

---------

Co-authored-by: floriankilian <floriankilian@users.noreply.github.com>
This commit is contained in:
floriankilian
2025-08-01 07:01:10 +02:00
committed by GitHub
parent ee459b7410
commit bd59cd61cb
10 changed files with 211 additions and 54 deletions
+1 -2
View File
@@ -60,12 +60,11 @@ export interface LobbyConfig {
}
export function joinLobby(
eventBus: EventBus,
lobbyConfig: LobbyConfig,
onPrestart: () => void,
onJoin: () => void,
): () => void {
const eventBus = new EventBus();
console.log(
`joining lobby: gameID: ${lobbyConfig.gameID}, clientID: ${lobbyConfig.clientID}`,
);
+52 -15
View File
@@ -14,7 +14,12 @@ import {
mapCategories,
} from "../core/game/Game";
import { UserSettings } from "../core/game/UserSettings";
import { GameConfig, GameInfo, TeamCountConfig } from "../core/Schemas";
import {
ClientInfo,
GameConfig,
GameInfo,
TeamCountConfig,
} from "../core/Schemas";
import { generateID } from "../core/Util";
import "./components/baseComponents/Modal";
import "./components/Difficulties";
@@ -40,9 +45,10 @@ export class HostLobbyModal extends LitElement {
@state() private instantBuild: boolean = false;
@state() private lobbyId = "";
@state() private copySuccess = false;
@state() private players: string[] = [];
@state() private clients: ClientInfo[] = [];
@state() private useRandomMap: boolean = false;
@state() private disabledUnits: UnitType[] = [UnitType.Factory];
@state() private lobbyCreatorClientID: string = "";
@state() private lobbyIdVisible: boolean = true;
private playersInterval: NodeJS.Timeout | null = null;
@@ -395,29 +401,45 @@ export class HostLobbyModal extends LitElement {
<!-- Lobby Selection -->
<div class="options-section">
<div class="option-title">
${this.players.length}
${this.clients.length}
${
this.players.length === 1
this.clients.length === 1
? translateText("host_modal.player")
: translateText("host_modal.players")
}
</div>
<div class="players-list">
${this.players.map(
(player) => html`<span class="player-tag">${player}</span>`,
${this.clients.map(
(client) => html`
<span class="player-tag">
${client.username}
${client.clientID === this.lobbyCreatorClientID
? html`<span class="host-badge"
>(${translateText("host_modal.host_badge")})</span
>`
: html`
<button
class="remove-player-btn"
@click=${() => this.kickPlayer(client.clientID)}
title="Remove ${client.username}"
>
×
</button>
`}
</span>
`,
)}
</div>
</div>
<div class="start-game-button-container">
<button
@click=${this.startGame}
?disabled=${this.players.length < 2}
?disabled=${this.clients.length < 2}
class="start-game-button"
>
${
this.players.length === 1
this.clients.length === 1
? translateText("host_modal.waiting")
: translateText("host_modal.start")
}
@@ -434,12 +456,13 @@ export class HostLobbyModal extends LitElement {
}
public open() {
this.lobbyCreatorClientID = generateID();
this.lobbyIdVisible = this.userSettings.get(
"settings.lobbyIdVisibility",
true,
);
createLobby()
createLobby(this.lobbyCreatorClientID)
.then((lobby) => {
this.lobbyId = lobby.gameID;
// join lobby
@@ -449,7 +472,7 @@ export class HostLobbyModal extends LitElement {
new CustomEvent("join-lobby", {
detail: {
gameID: this.lobbyId,
clientID: generateID(),
clientID: this.lobbyCreatorClientID,
} as JoinLobbyEvent,
bubbles: true,
composed: true,
@@ -633,17 +656,29 @@ export class HostLobbyModal extends LitElement {
.then((response) => response.json())
.then((data: GameInfo) => {
console.log(`got game info response: ${JSON.stringify(data)}`);
this.players = data.clients?.map((p) => p.username) ?? [];
this.clients = data.clients ?? [];
});
}
private kickPlayer(clientID: string) {
// Dispatch event to be handled by WebSocket instead of HTTP
this.dispatchEvent(
new CustomEvent("kick-player", {
detail: { target: clientID },
bubbles: true,
composed: true,
}),
);
}
}
async function createLobby(): Promise<GameInfo> {
async function createLobby(creatorClientID: string): Promise<GameInfo> {
const config = await getServerConfigFromClient();
try {
const id = generateID();
const response = await fetch(
`/${config.workerPath(id)}/api/create_game/${id}`,
`/${config.workerPath(id)}/api/create_game/${id}?creatorClientID=${encodeURIComponent(creatorClientID)}`,
{
method: "POST",
headers: {
@@ -654,6 +689,8 @@ async function createLobby(): Promise<GameInfo> {
);
if (!response.ok) {
const errorText = await response.text();
console.error("Server error response:", errorText);
throw new Error(`HTTP error! status: ${response.status}`);
}
@@ -663,6 +700,6 @@ async function createLobby(): Promise<GameInfo> {
return data as GameInfo;
} catch (error) {
console.error("Error creating lobby:", error);
throw error; // Re-throw the error so the caller can handle it
throw error;
}
}
+14
View File
@@ -1,6 +1,7 @@
import favicon from "../../resources/images/Favicon.svg";
import version from "../../resources/version.txt";
import { UserMeResponse } from "../core/ApiSchemas";
import { EventBus } from "../core/EventBus";
import { GameRecord, GameStartInfo, ID } from "../core/Schemas";
import { ServerConfig } from "../core/configuration/Config";
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
@@ -24,6 +25,7 @@ import "./PublicLobby";
import { PublicLobby } from "./PublicLobby";
import { SinglePlayerModal } from "./SinglePlayerModal";
import { TerritoryPatternsModal } from "./TerritoryPatternsModal";
import { SendKickPlayerIntentEvent } from "./Transport";
import { UserSettingModal } from "./UserSettingModal";
import "./UsernameInput";
import { UsernameInput } from "./UsernameInput";
@@ -77,6 +79,7 @@ export interface JoinLobbyEvent {
class Client {
private gameStop: (() => void) | null = null;
private eventBus: EventBus = new EventBus();
private usernameInput: UsernameInput | null = null;
private flagInput: FlagInput | null = null;
@@ -163,6 +166,7 @@ class Client {
setFavicon();
document.addEventListener("join-lobby", this.handleJoinLobby.bind(this));
document.addEventListener("leave-lobby", this.handleLeaveLobby.bind(this));
document.addEventListener("kick-player", this.handleKickPlayer.bind(this));
const spModal = document.querySelector(
"single-player-modal",
@@ -429,6 +433,7 @@ class Client {
const config = await getServerConfigFromClient();
this.gameStop = joinLobby(
this.eventBus,
{
gameID: lobby.gameID,
serverConfig: config,
@@ -514,6 +519,15 @@ class Client {
this.gameStop = null;
this.publicLobby.leaveLobby();
}
private handleKickPlayer(event: CustomEvent) {
const { target } = event.detail;
// Forward to eventBus if available
if (this.eventBus) {
this.eventBus.emit(new SendKickPlayerIntentEvent(target));
}
}
}
// Initialize the client when the DOM is loaded
+15
View File
@@ -165,6 +165,10 @@ export class MoveWarshipIntentEvent implements GameEvent {
) {}
}
export class SendKickPlayerIntentEvent implements GameEvent {
constructor(public readonly target: string) {}
}
export class Transport {
private socket: WebSocket | null = null;
@@ -241,6 +245,9 @@ export class Transport {
this.eventBus.on(MoveWarshipIntentEvent, (e) => {
this.onMoveWarshipEvent(e);
});
this.eventBus.on(SendKickPlayerIntentEvent, (e) =>
this.onSendKickPlayerIntent(e),
);
}
private startPing() {
@@ -611,6 +618,14 @@ export class Transport {
});
}
private onSendKickPlayerIntent(event: SendKickPlayerIntentEvent) {
this.sendIntent({
type: "kick_player",
clientID: this.lobbyConfig.clientID,
target: event.target,
});
}
private sendIntent(intent: Intent) {
if (this.isLocal || this.socket?.readyState === WebSocket.OPEN) {
const msg = {
+31 -18
View File
@@ -251,15 +251,15 @@ label.option-card:hover {
}
.player-tag {
display: flex;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.1);
padding: 4px 16px;
gap: 8px;
background: #2a2a2a;
color: #fff;
padding: 8px 12px;
margin: 4px;
border-radius: 16px;
font-size: 14px;
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.2);
}
#bots-count,
@@ -625,18 +625,6 @@ label.option-card:hover {
padding: 0 16px;
}
.player-tag {
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.1);
padding: 4px 16px;
border-radius: 16px;
font-size: 14px;
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.2);
}
/* News Button Notification */
news-button .active button {
position: relative;
@@ -670,3 +658,28 @@ news-button .active button::after {
transform: scale(1.4);
}
}
.host-badge {
font-size: 11px;
color: #4caf50;
font-weight: bold;
}
.remove-player-btn {
width: 16px;
height: 16px;
border: none;
background: #ff4444;
color: white;
border-radius: 50%;
font-size: 11px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
margin-left: 4px;
}
.remove-player-btn:hover {
background: #ff6666;
}