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:
co-authored by
Claude Fable 5
parent
4c7633097b
commit
9af7bd80ce
@@ -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" });
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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`));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user