From 9af7bd80cea49544dd449e2e6d734ee0680eac64 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Wed, 26 Aug 2026 11:05:42 -0400 Subject: [PATCH] The host clears seats and closes rooms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two lobby-lifecycle gaps from tonight's duels (a token lost to a client bug left an unkickable ghost seat holding a chair in VQJW). kickSeat: the host removes any other seat from an unstarted room — the kick is a ledger line so restarts replay it, and a kicked live socket is set adrift with notice. abandonRoom: the host dissolves a lobby or a finished game; every member is notified and detached, and the ledger is archived to rooms-abandoned/ — moved, never deleted. The lobby shows hosts a ✕ per seat and an abandon-room control. Proven live: kick with notice, kick surviving a server restart, and abandonment archiving its ledger. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF --- packages/server/src/index.ts | 34 +++++++++++++++++++++++++++++++ packages/server/src/rooms.ts | 37 ++++++++++++++++++++++++++++++++-- packages/server/src/store.ts | 26 ++++++++++++++++++++++-- packages/web/src/App.svelte | 8 ++++++++ packages/web/src/net.svelte.ts | 29 ++++++++++++++++++++++++++ 5 files changed, 130 insertions(+), 4 deletions(-) diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 784bae5..9ca7352 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -57,6 +57,8 @@ import { summarize, viewForPlayer, type Room, + kickSeat, + abandonRoom, } from "./rooms"; import { engagementStats, recordHotseat } from "./stats"; import { getShare, loadShares, mintShare } from "./shares"; @@ -526,6 +528,38 @@ wss.on("connection", (socket) => { session.token = null; break; } + case "kickSeat": { + const room = session.roomId ? getRoom(session.roomId) : undefined; + if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" }); + const problem = kickSeat(room, session.playerId, String(msg.name ?? "")); + if (problem) return send(socket, { type: "error", message: problem }); + // A kicked live socket is set adrift so it cannot act on a seat it lost. + for (const other of sessions) { + if (other.roomId === room.id && other.playerId === msg.name) { + other.playerId = null; + other.roomId = null; + other.token = null; + send(other.socket, { type: "kicked", roomId: room.id }); + } + } + broadcastRoomState(room); + break; + } + case "abandonRoom": { + const room = session.roomId ? getRoom(session.roomId) : undefined; + if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" }); + const problem = abandonRoom(room, session.playerId); + if (problem) return send(socket, { type: "error", message: problem }); + for (const other of sessions) { + if (other.roomId === room.id) { + other.playerId = null; + other.roomId = null; + other.token = null; + send(other.socket, { type: "roomAbandoned", roomId: room.id }); + } + } + break; + } case "addBot": { const room = session.roomId ? getRoom(session.roomId) : undefined; if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" }); diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index 8279f29..129c3d4 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -22,7 +22,7 @@ import { type PlayerId, CURRENT_RULES_REV, } from "@wizwar/engine"; -import { appendLine, ensureDataDir, readAllRooms, roomFileExists, type RoomLine } from "./store"; +import { appendLine, archiveRoomFile, ensureDataDir, readAllRooms, roomFileExists, type RoomLine } from "./store"; import { recordRoom } from "./stats"; export interface LoggedCommand { @@ -153,6 +153,33 @@ export function joinRoom( return { token: fresh }; } +/** The host shows an unclaimed (or unwanted) seat the door — lobby only: + * once the deal happens, every seat is a player in the game's record. */ +export function kickSeat(room: Room, byId: PlayerId, name: PlayerId): string | null { + if (byId !== room.hostId) return "only the host may clear a seat"; + if (room.state) return "the game has started — seats are settled"; + if (name === room.hostId) return "the host cannot kick themselves — abandon the room instead"; + if (!room.players.includes(name)) return "no such seat"; + room.players = room.players.filter((p) => p !== name); + room.tokens.delete(name); + room.bots.delete(name); + room.colorChoices.delete(name); + appendLine(room.id, { kind: "kick", name }); + recordRoom(room); + return null; +} + +/** The host dissolves the room: a lobby that never dealt, or a finished + * game done being remembered. The ledger is archived, never deleted. */ +export function abandonRoom(room: Room, byId: PlayerId): string | null { + if (byId !== room.hostId) return "only the host may abandon the room"; + if (room.state && room.state.phase === "playing") return "the game is still being played"; + appendLine(room.id, { kind: "abandon", by: byId, at: new Date().toISOString() }); + archiveRoomFile(room.id); + rooms.delete(room.id); + return null; +} + /** Everyone gets their chosen standee; the undecided get the first free one. */ export function resolveColors(room: Room): number[] { const taken = new Set(); @@ -601,8 +628,14 @@ export function loadPersistedRooms(): void { chat: [], bots: new Map(), }; + if (lines.some((l) => l.kind === "abandon")) continue; for (const line of lines.slice(1)) { - if (line.kind === "join") { + if (line.kind === "kick") { + room.players = room.players.filter((p) => p !== line.name); + room.tokens.delete(line.name); + room.bots.delete(line.name); + room.colorChoices.delete(line.name); + } else if (line.kind === "join") { if (line.bot) { room.players.push(line.name); room.bots.set(line.name, { diff --git a/packages/server/src/store.ts b/packages/server/src/store.ts index 76cebde..1ccf171 100644 --- a/packages/server/src/store.ts +++ b/packages/server/src/store.ts @@ -3,7 +3,7 @@ // command. Because the engine is deterministic, replaying a file rebuilds // the exact game state — server restarts lose nothing. -import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs"; +import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync } from "node:fs"; import { join } from "node:path"; export interface RoomMetaLine { @@ -58,7 +58,19 @@ export interface ChatLine { at: string; } -export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine | ChatLine; +export interface KickLine { + kind: "kick"; + /** The seat the host removed from an unstarted room. */ + name: string; +} + +export interface AbandonLine { + kind: "abandon"; + by: string; + at: string; +} + +export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine | ChatLine | KickLine | AbandonLine; const DATA_DIR = process.env.WIZWAR_DATA_DIR ?? join(process.cwd(), "data", "rooms"); @@ -103,3 +115,13 @@ export function readAllRooms(): Map { } return rooms; } + +/** Retire an abandoned room's ledger to the graveyard — never deleted, + * only moved out of the living rooms directory. */ +export function archiveRoomFile(roomId: string): void { + const src = join(DATA_DIR, `${roomId}.jsonl`); + if (!existsSync(src)) return; + const graveyard = join(DATA_DIR, "..", "rooms-abandoned"); + mkdirSync(graveyard, { recursive: true }); + renameSync(src, join(graveyard, `${roomId}.${Date.now()}.jsonl`)); +} diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 126a735..0f86f2d 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -1812,6 +1812,10 @@ {/if} {p}{p === net.hostId ? " — host" : ""}{net.roomBots[p] ? ` ⚙ ${net.roomBots[p]}` : chosen === undefined ? " — choosing…" : ""} + {#if net.you === net.hostId && p !== net.hostId} + + {/if} {/each} @@ -1835,6 +1839,8 @@ {/each} {#if net.you === net.hostId} + {#if net.players.length < 6} ⚙ seat a @@ -2462,6 +2468,8 @@ text-transform: uppercase; color: #8d8672; } + .kick { margin-left: 0.4rem; color: #a04545; } + .abandon { margin: 0.4rem 0; color: #a04545; border-color: #a04545; } .mast-version { font-family: "Courier Prime", monospace; font-size: 0.65rem; diff --git a/packages/web/src/net.svelte.ts b/packages/web/src/net.svelte.ts index 2f07bf7..58b3172 100644 --- a/packages/web/src/net.svelte.ts +++ b/packages/web/src/net.svelte.ts @@ -447,6 +447,14 @@ class Net { } break; } + case "kicked": + this.log = [...this.log, { text: `— the host cleared your seat in ${msg.roomId} —`, turn: null, notable: false }]; + this.leaveLocal(); + break; + case "roomAbandoned": + this.log = [...this.log, { text: `— the host closed room ${msg.roomId} —`, turn: null, notable: false }]; + this.leaveLocal(); + break; case "transferCode": this.transferCode = { code: msg.code, expiresAt: msg.expiresAt }; break; @@ -565,6 +573,14 @@ class Net { this.send({ type: "addBot", ...(style ? { style } : {}), ...(tier ? { tier } : {}) }); } + kickSeat(name: string): void { + this.send({ type: "kickSeat", name }); + } + + abandonRoom(): void { + this.send({ type: "abandonRoom" }); + } + rollTableDie(): void { this.send({ type: "rollDie" }); } @@ -636,6 +652,19 @@ class Net { } /** Forget the remembered seat and return to the lobby. */ + /** Client-side teardown when the SERVER detached us (kick, abandon). */ + leaveLocal(): void { + localStorage.removeItem(SEAT_KEY); + this.roomId = null; + this.roomIdPending = null; + this.view = null; + this.started = false; + this.players = []; + this.token = null; + this.spectating = false; + this.audience = 0; + } + leave(): void { this.send({ type: "leave" }); // detach server-side too (frees a gallery seat) localStorage.removeItem(SEAT_KEY);