The host clears seats and closes rooms

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
This commit is contained in:
Eric Wagoner
2026-08-26 11:05:42 -04:00
co-authored by Claude Fable 5
parent 4c7633097b
commit 9af7bd80ce
5 changed files with 130 additions and 4 deletions
+34
View File
@@ -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" });
+35 -2
View File
@@ -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<number>();
@@ -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, {
+24 -2
View File
@@ -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<string, RoomLine[]> {
}
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`));
}
+8
View File
@@ -1812,6 +1812,10 @@
<span class="dot" style:background="#b3a687"></span>
{/if}
{p}{p === net.hostId ? " — host" : ""}{net.roomBots[p] ? ` ⚙ ${net.roomBots[p]}` : chosen === undefined ? " — choosing…" : ""}
{#if net.you === net.hostId && p !== net.hostId}
<button class="stamp tiny kick" title="clear this seat"
onclick={() => net.kickSeat(p)}>✕</button>
{/if}
</li>
{/each}
</ul>
@@ -1835,6 +1839,8 @@
{/each}
</div>
{#if net.you === net.hostId}
<button class="stamp tiny abandon" title="dissolve this room for everyone"
onclick={() => net.abandonRoom()}>abandon room</button>
{#if net.players.length < 6}
<span class="bot-row">
<span class="bot-label">⚙ seat a</span>
@@ -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;
+29
View File
@@ -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);