mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 14:41:35 +00:00
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:
@@ -215,7 +215,8 @@
|
||||
"player": "Player",
|
||||
"players": "Players",
|
||||
"waiting": "Waiting for players...",
|
||||
"start": "Start Game"
|
||||
"start": "Start Game",
|
||||
"host_badge": "Host"
|
||||
},
|
||||
"team_colors": {
|
||||
"red": "Red",
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
+10
-1
@@ -41,7 +41,9 @@ export type Intent =
|
||||
| QuickChatIntent
|
||||
| MoveWarshipIntent
|
||||
| MarkDisconnectedIntent
|
||||
| UpgradeStructureIntent;
|
||||
| UpgradeStructureIntent
|
||||
| KickPlayerIntent;
|
||||
|
||||
export type AttackIntent = z.infer<typeof AttackIntentSchema>;
|
||||
export type CancelAttackIntent = z.infer<typeof CancelAttackIntentSchema>;
|
||||
export type SpawnIntent = z.infer<typeof SpawnIntentSchema>;
|
||||
@@ -72,6 +74,7 @@ export type MarkDisconnectedIntent = z.infer<
|
||||
export type AllianceExtensionIntent = z.infer<
|
||||
typeof AllianceExtensionIntentSchema
|
||||
>;
|
||||
export type KickPlayerIntent = z.infer<typeof KickPlayerIntentSchema>;
|
||||
|
||||
export type Turn = z.infer<typeof TurnSchema>;
|
||||
export type GameConfig = z.infer<typeof GameConfigSchema>;
|
||||
@@ -356,6 +359,11 @@ export const MarkDisconnectedIntentSchema = BaseIntentSchema.extend({
|
||||
isDisconnected: z.boolean(),
|
||||
});
|
||||
|
||||
export const KickPlayerIntentSchema = BaseIntentSchema.extend({
|
||||
type: z.literal("kick_player"),
|
||||
target: ID,
|
||||
});
|
||||
|
||||
const IntentSchema = z.discriminatedUnion("type", [
|
||||
AttackIntentSchema,
|
||||
CancelAttackIntentSchema,
|
||||
@@ -377,6 +385,7 @@ const IntentSchema = z.discriminatedUnion("type", [
|
||||
MoveWarshipIntentSchema,
|
||||
QuickChatIntentSchema,
|
||||
AllianceExtensionIntentSchema,
|
||||
KickPlayerIntentSchema,
|
||||
]);
|
||||
|
||||
//
|
||||
|
||||
+25
-14
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user