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
+25 -14
View File
@@ -28,20 +28,31 @@ export class GameManager {
return false;
}
createGame(id: GameID, gameConfig: GameConfig | undefined) {
const game = new GameServer(id, this.log, Date.now(), this.config, {
gameMap: GameMapType.World,
gameType: GameType.Private,
difficulty: Difficulty.Medium,
disableNPCs: false,
infiniteGold: false,
infiniteTroops: false,
instantBuild: false,
gameMode: GameMode.FFA,
bots: 400,
disabledUnits: [],
...gameConfig,
});
createGame(
id: GameID,
gameConfig: GameConfig | undefined,
creatorClientID?: string,
) {
const game = new GameServer(
id,
this.log,
Date.now(),
this.config,
{
gameMap: GameMapType.World,
gameType: GameType.Private,
difficulty: Difficulty.Medium,
disableNPCs: false,
infiniteGold: false,
infiniteTroops: false,
instantBuild: false,
gameMode: GameMode.FFA,
bots: 400,
disabledUnits: [],
...gameConfig,
},
creatorClientID,
);
this.games.set(id, game);
return game;
}
+50 -1
View File
@@ -41,7 +41,7 @@ export class GameServer {
private turns: Turn[] = [];
private intents: Intent[] = [];
public activeClients: Client[] = [];
// Used for record record keeping
private LobbyCreatorID: string | undefined;
private allClients: Map<ClientID, Client> = new Map();
private clientsDisconnectedStatus: Map<ClientID, boolean> = new Map();
private _hasStarted = false;
@@ -71,8 +71,10 @@ export class GameServer {
public readonly createdAt: number,
private config: ServerConfig,
public gameConfig: GameConfig,
lobbyCreatorID?: string,
) {
this.log = log_.child({ gameID: id });
this.LobbyCreatorID = lobbyCreatorID ?? undefined;
}
public updateGameConfig(gameConfig: Partial<GameConfig>): void {
@@ -118,6 +120,13 @@ export class GameServer {
});
return;
}
// Log when lobby creator joins private game
if (client.clientID === this.LobbyCreatorID) {
this.log.info("Lobby creator joined", {
gameID: this.id,
creatorID: this.LobbyCreatorID,
});
}
this.log.info("client (re)joining game", {
clientID: client.clientID,
persistentID: client.persistentID,
@@ -223,6 +232,42 @@ export class GameServer {
);
return;
}
// Handle kick_player intent via WebSocket
if (clientMsg.intent.type === "kick_player") {
const authenticatedClientID = client.clientID;
// Check if the authenticated client is the lobby creator
if (authenticatedClientID !== this.LobbyCreatorID) {
this.log.warn(`Only lobby creator can kick players`, {
clientID: authenticatedClientID,
creatorID: this.LobbyCreatorID,
target: clientMsg.intent.target,
gameID: this.id,
});
return;
}
// Don't allow lobby creator to kick themselves
if (authenticatedClientID === clientMsg.intent.target) {
this.log.warn(`Cannot kick yourself`, {
clientID: authenticatedClientID,
});
return;
}
// Log and execute the kick
this.log.info(`Lobby creator initiated kick of player`, {
creatorID: authenticatedClientID,
target: clientMsg.intent.target,
gameID: this.id,
kickMethod: "websocket",
});
this.kickClient(clientMsg.intent.target);
return;
}
this.addIntent(clientMsg.intent);
}
if (clientMsg.type === "ping") {
@@ -453,6 +498,10 @@ export class GameServer {
}
}
public isPrivateLobbyCreator(clientID: string): boolean {
return this.LobbyCreatorID === clientID;
}
phase(): GamePhase {
const now = Date.now();
const alive: Client[] = [];
+11 -2
View File
@@ -15,6 +15,7 @@ import {
ClientMessageSchema,
GameRecord,
GameRecordSchema,
ID,
ServerErrorMessage,
} from "../core/Schemas";
import { CreateGameInputSchema, GameInputSchema } from "../core/WorkerSchemas";
@@ -90,6 +91,13 @@ export function startWorker() {
"/api/create_game/:id",
gatekeeper.httpHandler(LimiterType.Post, async (req, res) => {
const id = req.params.id;
const creatorClientID = (() => {
if (typeof req.query.creatorClientID !== "string") return undefined;
const trimmed = req.query.creatorClientID.trim();
return ID.safeParse(trimmed).success ? trimmed : undefined;
})();
if (!id) {
log.warn(`cannot create game, id not found`);
return res.status(400).json({ error: "Game ID is required" });
@@ -122,10 +130,11 @@ export function startWorker() {
return res.status(400).json({ error: "Worker, game id mismatch" });
}
const game = gm.createGame(id, gc);
// Pass creatorClientID to createGame
const game = gm.createGame(id, gc, creatorClientID);
log.info(
`Worker ${workerId}: IP ${ipAnonymize(clientIP)} creating game ${game.isPublic() ? "Public" : "Private"} with id ${id}`,
`Worker ${workerId}: IP ${ipAnonymize(clientIP)} creating ${game.isPublic() ? "Public" : "Private"}${gc?.gameMode ? ` ${gc.gameMode}` : ""} game with id ${id}${creatorClientID ? `, creator: ${creatorClientID}` : ""}`,
);
res.json(game.gameInfo());
}),