diff --git a/deploy/README.md b/deploy/README.md index d6d2ca3..b2cbade 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -53,7 +53,12 @@ longer replays becomes unreachable after restart, so this is not optional. script, configure rclone (the script refuses to run while the config still holds its CHANGE_ME placeholder), and add the cron entry by hand. - A player whose browser lost their seat (Safari's seven days, an app's own - browser, cleared data) writes to the keeper. On the droplet, + browser, cleared data) asks the host, whose "seats" button makes a + four-word phrase that re-seats them on a new token. The same panel lets the + host give a seat to the clockwork in a game under way, for a player who + cannot take their turns; a `standIn` line on the ledger records it, and a + later reseat gives the seat back. Failing the host, they write to the + keeper. On the droplet, `wizwar-reseat.sh ROOM` lists who sits at the table and when each last moved or spoke; `wizwar-reseat.sh ROOM NAME` gives that wizard's seat a new token and prints a link carrying it in the fragment. The old token opens diff --git a/deploy/wizwar-visitors.sh b/deploy/wizwar-visitors.sh index 898916d..2dcc74f 100755 --- a/deploy/wizwar-visitors.sh +++ b/deploy/wizwar-visitors.sh @@ -41,6 +41,7 @@ for f in glob.glob("/var/lib/wizwar/rooms/*.jsonl"): ca = meta.get("createdAt", "") if not ca: continue humans = [meta["hostId"]]; bots = []; started = False; cmds = 0; chat = []; last = ca; steps = [] + standing = set(); stood = [] # seats the host gave the clockwork, and who and when for l in lines[1:]: try: x = json.loads(l) except Exception: continue @@ -48,15 +49,19 @@ for f in glob.glob("/var/lib/wizwar/rooms/*.jsonl"): if k == "join": (bots if x.get("bot") else humans).append(x["name"]) elif k == "start": started = True + elif k == "standIn": + standing.add(x["name"]); stood.append((x.get("at", "")[11:16], x["name"], "to the clockwork")) + elif k == "reseat" and x["name"] in standing: + standing.discard(x["name"]); stood.append((x.get("at", "")[11:16], x["name"], "back to a person")) elif k == "command": cmds += 1; last = x.get("at", last) - steps.append((x.get("playerId"), x["command"].get("type"), x.get("at", ""))) + steps.append((x.get("playerId"), x["command"].get("type"), x.get("at", ""), x.get("playerId") in standing)) elif k == "chat": last = x.get("at", last) if x["player"] in humans: chat.append((x.get("at", "")[11:16], x["player"], x["text"])) for h in humans: if h not in first_seen or ca < first_seen[h]: first_seen[h] = ca - rooms.append(dict(id=meta["id"], created=ca, humans=humans, bots=bots, started=started, cmds=cmds, last=last, chat=chat, steps=steps)) + rooms.append(dict(id=meta["id"], created=ca, humans=humans, bots=bots, started=started, cmds=cmds, last=last, chat=chat, steps=steps, stood=stood)) today = sorted([r for r in rooms if r["created"].startswith(day)], key=lambda r: r["created"]) names = sorted({h for r in today for h in r["humans"]}) @@ -68,12 +73,13 @@ for r in today: if r["cmds"]: t0 = datetime.datetime.fromisoformat(r["created"].replace("Z", "+00:00")); t1 = datetime.datetime.fromisoformat(r["last"].replace("Z", "+00:00")) span = "%d min" % round((t1 - t0).total_seconds() / 60) - human_cmds = [s for s in r["steps"] if s[0] in r["humans"]] + human_cmds = [s for s in r["steps"] if s[0] in r["humans"] and not s[3]] last_human = ("last human move: %s %s at %s" % (human_cmds[-1][0], human_cmds[-1][1], human_cmds[-1][2][11:16])) if human_cmds else "no human move" state = "lobby only" if not r["started"] else ("started, no moves" if not r["cmds"] else "in play") print(" %s %s %s%s %s %d moves (%d human) %s %s" % ( r["created"][11:16], r["id"], "+".join(r["humans"]), (" vs %d bot%s" % (len(r["bots"]), "" if len(r["bots"]) == 1 else "s")) if r["bots"] else "", state, r["cmds"], len(human_cmds), span, last_human)) + for at, who, what in r["stood"]: print(" %s %s's seat goes %s" % (at, who, what)) for at, who, text in r["chat"]: print(" %s %s: %s" % (at, who, text)) # --- the desk --- diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index 7b434b8..ce7ac12 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -706,6 +706,9 @@ export type GameEvent = | { type: "attackMisdirected"; attacker: PlayerId; intended: PlayerId; rolledDirection: Side; newTarget: PlayerId | null } | { type: "dieRolled"; player: PlayerId | null; roll: number; purpose: string } | { type: "tableTalk"; player: PlayerId; text: string } + /** The room's word, not the maze's: the host gave a person's seat to the + * clockwork, or the seat went back to a person. */ + | { type: "seatChanged"; player: PlayerId; to: "clockwork" | "person"; by?: PlayerId } | { type: "retreatedInHorror"; player: PlayerId; from: Cell; to: Cell } | { type: "illusionWallCreated"; caster: PlayerId; edge: { cell: Cell; side: Side } } | { type: "illusionTested"; player: PlayerId; edge: string; result: "believes" | "seesThrough" } diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 40be0c7..148fb15 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -64,6 +64,7 @@ import { GALLERY_MARK, driveOneAutomaton, makeTransferCode, + makeSeatPhrase, redactFor, roomCount, runCommand, @@ -74,6 +75,7 @@ import { viewForPlayer, type Room, kickSeat, + standIn, abandonRoom, KEEPER, MAX_SEATS, @@ -544,11 +546,19 @@ openKeeperDoor({ port: Number(process.env.KEEPER_PORT ?? 9787), publicUrl: PUBLIC_URL, keyFile: process.env.KEEPER_KEY_FILE ?? "/etc/wizwar/keeper.key", - onReseat: (room, name) => { - for (const s of sessions) if (s.roomId === room.id && s.playerId === name) s.socket.close(); + onReseat: (room, name, returned) => { + closeSeat(room, name); + if (returned) broadcast(room, () => ({ type: "events", events: [returned] })); + broadcastRoomState(room); }, }); +/** Close every socket still seated as NAME: its reconnect offers the old + * token, which opens nothing now. */ +function closeSeat(room: Room, name: PlayerId): void { + for (const s of sessions) if (s.roomId === room.id && s.playerId === name) s.socket.close(); +} + interface Session { socket: WebSocket; playerId: PlayerId | null; @@ -568,6 +578,8 @@ interface Session { roomsCreated: number; lastCatchUpAt: number; hotseatReports: number; + /** Answered the last protocol ping. */ + alive: boolean; } function underRateLimit(s: Session): boolean { @@ -581,6 +593,21 @@ function underRateLimit(s: Session): boolean { const sessions = new Set(); +// A browser that crashes or loses its network never closes its socket, and +// TCP may take minutes to notice. Every beat each socket is pinged at the +// protocol level, which browsers answer on their own, and one that left the +// last ping unanswered is dropped. +setInterval(() => { + for (const s of sessions) { + if (!s.alive) { + s.socket.terminate(); + continue; + } + s.alive = false; + s.socket.ping(); + } +}, Number(process.env.WIZWAR_PING_MS ?? 30_000)); + function send(socket: WebSocket, message: unknown): void { if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message)); } @@ -620,12 +647,15 @@ function roomInfo(room: Room) { keeperZone: KEEPER_ZONE, galleryTalk: room.galleryTalk, colors: Object.fromEntries(room.colorChoices), + /** People's seats the clockwork is playing for now. */ + standIns: [...room.bots].filter(([, b]) => b.standIn).map(([name]) => name), bots: Object.fromEntries( // A mystery machine keeps its mood only while the game lives: once it // ends, the hands go face-up and so does the temperament. [...room.bots].map(([name, b]) => [ name, - room.state?.phase === "finished" + b.standIn ? "standing in" + : room.state?.phase === "finished" ? `${b.tier} ${b.style}${b.secret ? " 🎭" : ""}` : `${b.tier} ${b.secret ? "mystery" : b.style}`, ]), @@ -679,6 +709,8 @@ function banterTrigger( * deeds, bystanders on their suffering. Rarely, and one voice at most. */ function botRemark(room: Room, actor: string, events: { type: string; [k: string]: unknown }[]): void { for (const [seat, bot] of room.bots) { + // A stand-in plays under a person's name, and does not speak for them. + if (bot.standIn) continue; const lines = BOT_LINES[bot.style] ?? {}; for (const e of events) { const trigger = banterTrigger(e, seat, actor); @@ -720,7 +752,7 @@ function runBots(room: Room): void { if (room.state?.phase === "finished") broadcast(room, () => roomInfo(room)); // A refused brain-choice becomes a visible stumble, in character — // an idle bot turn should read as hesitation, never as nothing. - if (step.hesitated) { + if (step.hesitated && !room.bots.get(step.seat)?.standIn) { const said = addChat(room, step.seat, HESITATION_LINES[Math.floor(Math.random() * HESITATION_LINES.length)]!); if (!("error" in said)) { broadcast(room, () => ({ type: "chat", player: step.seat, text: said.text, at: said.at })); @@ -749,9 +781,10 @@ wss.on("connection", (socket, req) => { socket, playerId: null, roomId: null, spectator: false, token: null, claimFails: 0, address: clientAddress(req.headers, req.socket.remoteAddress), bucket: 30, lastRefill: Date.now(), overLimitStrikes: 0, - roomsCreated: 0, lastCatchUpAt: 0, hotseatReports: 0, + roomsCreated: 0, lastCatchUpAt: 0, hotseatReports: 0, alive: true, }; sessions.add(session); + socket.on("pong", () => { session.alive = true; }); // A socket's own error (a frame over the limit, a malformed one) closes that socket, never the server. socket.on("error", () => socket.terminate()); send(socket, { type: "welcome", game: "wizwar" }); @@ -904,6 +937,35 @@ wss.on("connection", (socket, req) => { broadcastRoomState(room); break; } + case "standIn": { + const room = session.roomId ? getRoom(session.roomId) : undefined; + if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" }); + const name = String(msg.name ?? ""); + const result = standIn(room, session.playerId, name); + if ("error" in result) return send(socket, { type: "error", message: result.error }); + // The person's live sockets are set adrift: the seat plays itself now. + for (const other of sessions) { + if (other.roomId === room.id && other.playerId === name) { + other.playerId = null; + other.roomId = null; + other.token = null; + send(other.socket, { type: "standingIn", roomId: room.id }); + } + } + broadcast(room, () => ({ type: "events", events: [result.event] })); + broadcastRoomState(room); + runBots(room); + break; + } + case "makeSeatPhrase": { + const room = session.roomId ? getRoom(session.roomId) : undefined; + if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" }); + const name = String(msg.name ?? ""); + const result = makeSeatPhrase(room, session.playerId, name); + if ("error" in result) return send(socket, { type: "error", message: result.error }); + send(socket, { type: "seatPhrase", name, code: result.code, expiresAt: result.expiresAt }); + 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" }); @@ -1036,7 +1098,13 @@ wss.on("connection", (socket, req) => { return send(socket, { type: "error", message: result.error }); } session.claimFails = 0; - send(socket, { type: "transferClaimed", seat: result }); + const claimed = getRoom(result.roomId); + if (result.reseated && claimed) { + closeSeat(claimed, result.name); + if (result.returned) broadcast(claimed, () => ({ type: "events", events: [result.returned] })); + broadcastRoomState(claimed); + } + send(socket, { type: "transferClaimed", seat: { roomId: result.roomId, name: result.name, token: result.token } }); break; } case "catchUp": { diff --git a/packages/server/src/keeper.ts b/packages/server/src/keeper.ts index f5295d8..41910c9 100644 --- a/packages/server/src/keeper.ts +++ b/packages/server/src/keeper.ts @@ -8,7 +8,7 @@ import { timingSafeEqual } from "node:crypto"; import { readFileSync } from "node:fs"; import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; -import type { PlayerId } from "@wizwar/engine"; +import type { GameEvent, PlayerId } from "@wizwar/engine"; import { getRoom, reseat, seatsForKeeper, type Room } from "./rooms"; function send(res: ServerResponse, status: number, body: unknown): void { @@ -45,8 +45,9 @@ export interface KeeperDoor { port: number; publicUrl: string; keyFile: string; - /** Lets the caller set adrift any socket still seated on the old token. */ - onReseat: (room: Room, name: PlayerId) => void; + /** Lets the caller set adrift any socket still seated on the old token, + * and tell the table when the seat comes back from the clockwork. */ + onReseat: (room: Room, name: PlayerId, returned?: GameEvent) => void; } async function handle(req: IncomingMessage, res: ServerResponse, { publicUrl, keyFile, onReseat }: KeeperDoor): Promise { @@ -63,7 +64,7 @@ async function handle(req: IncomingMessage, res: ServerResponse, { publicUrl, ke const name = await readSeat(req); const result = reseat(room, name); if ("error" in result) return send(res, 404, result); - onReseat(room, name); + onReseat(room, name, result.returned); // The token rides the fragment, which the browser keeps to itself: it reaches neither Caddy's log nor the server. return send(res, 200, { name, link: `${publicUrl}/join/${room.id}#seat=${encodeURIComponent(name)}.${result.token}` }); } diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index 82650dd..ceed8e1 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -49,8 +49,9 @@ export interface Room { events: GameEvent[]; // full history (unredacted — redact per recipient) /** Table talk, persisted with the room (public to all seats). */ chat: { player: PlayerId; text: string; at: string }[]; - /** Seats the server itself plays: temperament, tier, and secrecy. */ - bots: Map; + /** Seats the server itself plays: temperament, tier, and secrecy. A + * stand-in plays a person's seat the host handed over mid-game. */ + bots: Map; /** Last time anything looked at this room (memory eviction clock). */ touchedAt?: number; /** A finished table that called for a rematch: where it went, and who called. */ @@ -173,9 +174,10 @@ export function callRematch( if (room.state?.phase !== "finished") return { error: "the game is not over yet" }; if (!room.players.includes(byId) || room.bots.has(byId)) return { error: "only a wizard of this table may call a rematch" }; if (room.rematch) return { roomId: room.rematch.roomId, token: null, created: false }; - const humans = room.players.filter((p) => !room.bots.has(p) && p !== byId); + // A seat the clockwork stood in for is still a person's, and is held for them. + const humans = room.players.filter((p) => personSeat(room, p) && p !== byId); const { room: next, token } = createRoom(byId, { of: room.id, expected: humans, expansion: room.expansion }); - for (const [, b] of room.bots) addAutomaton(next, b.secret ? undefined : b.style, b.tier); + for (const [, b] of room.bots) if (!b.standIn) addAutomaton(next, b.secret ? undefined : b.style, b.tier); room.rematch = { roomId: next.id, by: byId }; appendLine(room.id, { kind: "rematch", to: next.id, by: byId, at: new Date().toISOString() }); return { roomId: next.id, token, created: true }; @@ -299,6 +301,7 @@ export function joinRoom( token: string | null, ): { token: string } | { error: string } { if (room.players.includes(playerId)) { + if (room.bots.get(playerId)?.standIn) return { error: "the host gave this seat to the clockwork; ask them for a phrase to take it back" }; // Reclaiming an existing seat requires that seat's secret. if (tokenMatches(room, playerId, token)) return { token: token! }; return { error: "that wizard name is taken in this room" }; @@ -331,17 +334,64 @@ export function kickSeat(room: Room, byId: PlayerId, name: PlayerId): string | n return null; } -/** The keeper gives a person's seat a new token, for a player whose browser - * lost the old one. Whether the one asking holds the seat is the keeper's - * judgment; this carries it out. The old token opens nothing after. */ -export function reseat(room: Room, name: PlayerId): { token: string } | { error: string } { - if (!room.tokens.has(name) || room.bots.has(name)) return { error: "no person sits in that seat" }; +/** A person's seat gets a new token, for a player whose browser lost the + * old one: the keeper's door asks for it, or a phrase the host made is + * claimed. Whether the one asking holds the seat is their judgment; this + * carries it out. The old token opens nothing after, and a seat the + * clockwork was standing in for goes back to a person. */ +export function reseat(room: Room, name: PlayerId): { token: string; returned?: GameEvent } | { error: string } { + if (!personSeat(room, name)) return { error: "no person sits in that seat" }; const token = randomBytes(16).toString("hex"); - room.tokens.set(name, hashToken(token)); + const returned = seatToPerson(room, name, hashToken(token)); appendLine(room.id, { kind: "reseat", name, tokenHash: hashToken(token), at: new Date().toISOString() }); // A phrase minted with the old token would hand over a seat that no longer answers to it. for (const [code, t] of transfers) if (t.roomId === room.id && t.name === name) transfers.delete(code); - return { token }; + return { token, ...(returned ? { returned } : {}) }; +} + +/** A seat answers to a new token; one the clockwork was standing in for + * goes back to its person, and the chronicle says so. */ +function seatToPerson(room: Room, name: PlayerId, tokenHash: string): GameEvent | undefined { + const wasStanding = room.bots.get(name)?.standIn === true; + room.bots.delete(name); + room.tokens.set(name, tokenHash); + if (!wasStanding) return undefined; + const event: GameEvent = { type: "seatChanged", player: name, to: "person" }; + room.events.push(event); + return event; +} + +/** The clockwork takes a person's seat, and the chronicle says so. */ +function seatToClockwork(room: Room, name: PlayerId, by: PlayerId, style: AutomatonStyle, tier: AutomatonTier): GameEvent { + room.tokens.delete(name); + room.bots.set(name, { style, secret: true, tier, standIn: true }); + const event: GameEvent = { type: "seatChanged", player: name, to: "clockwork", by }; + room.events.push(event); + return event; +} + +/** A seat a person holds, or held until the host gave it to the clockwork. */ +function personSeat(room: Room, name: PlayerId): boolean { + const bot = room.bots.get(name); + return bot ? bot.standIn === true : room.tokens.has(name); +} + +/** The host gives a person's seat to the clockwork in a game under way, for + * a player who cannot take their turns: the wizard keeps hand, treasures + * and place, and an automaton plays on from here. The person's token opens + * nothing until a reseat gives the seat back. */ +export function standIn(room: Room, byId: PlayerId, name: PlayerId): { event: GameEvent } | { error: string } { + if (byId !== room.hostId) return { error: "only the host may give a seat to the clockwork" }; + if (room.state?.phase !== "playing") return { error: "only a game under way needs a stand-in" }; + if (name === room.hostId) return { error: "the host keeps their own seat" }; + if (!room.tokens.has(name) || room.bots.has(name)) return { error: "no person sits in that seat" }; + if (!room.state.players.find((p) => p.id === name)?.alive) return { error: `${name} is out of the game already` }; + const style = AUTOMATON_STYLES[randomInt(AUTOMATON_STYLES.length)]!; + const tier: AutomatonTier = "adept"; + const event = seatToClockwork(room, name, byId, style, tier); + for (const [code, t] of transfers) if (t.roomId === room.id && t.name === name) transfers.delete(code); + appendLine(room.id, { kind: "standIn", name, by: byId, style, tier, at: new Date().toISOString() }); + return { event }; } /** The table as the keeper sees it before re-seating: who sits where, and @@ -351,7 +401,7 @@ export function seatsForKeeper(room: Room): { name: PlayerId; host: boolean; bot for (const c of room.log) last.set(c.playerId, c.at); for (const c of room.chat) if ((last.get(c.player) ?? "") < c.at) last.set(c.player, c.at); return room.players.map((name) => { - const bot = room.bots.has(name); + const bot = room.bots.has(name) && !room.bots.get(name)!.standIn; return { name, host: name === room.hostId, bot, lastActed: bot ? null : (last.get(name) ?? null) }; }); } @@ -770,7 +820,9 @@ const TRANSFER_WORDS = [ interface PendingTransfer { roomId: string; name: PlayerId; - token: string; + /** The seat's own token, from its holder; null for a phrase the host + * made, which re-seats on its claim. */ + token: string | null; expiresAt: number; } @@ -783,7 +835,23 @@ export function makeTransferCode( rawToken: string | null, ): { code: string; expiresAt: number } | { error: string } { if (!tokenMatches(room, playerId, rawToken)) return { error: "you hold no seat in this room" }; - const token = rawToken!; + return mintPhrase(room.id, playerId, rawToken); +} + +/** The host makes a phrase for a person's seat, for a player whose browser + * lost it or who wants it back from the clockwork. Claiming it re-seats: + * the seat gets a new token and the old one opens nothing after. */ +export function makeSeatPhrase( + room: Room, + byId: PlayerId, + name: PlayerId, +): { code: string; expiresAt: number } | { error: string } { + if (byId !== room.hostId) return { error: "only the host may make a phrase for another seat" }; + if (!room.players.includes(name) || !personSeat(room, name)) return { error: "no person sits in that seat" }; + return mintPhrase(room.id, name, null); +} + +function mintPhrase(roomId: string, name: PlayerId, token: string | null): { code: string; expiresAt: number } | { error: string } { // Sweep expired codes while we are here. const now = Date.now(); for (const [code, t] of transfers) { @@ -795,7 +863,7 @@ export function makeTransferCode( code = Array.from({ length: 4 }, () => TRANSFER_WORDS[randomInt(TRANSFER_WORDS.length)]).join("-"); } while (transfers.has(code)); const expiresAt = now + TRANSFER_TTL_MS; - transfers.set(code, { roomId: room.id, name: playerId, token, expiresAt }); + transfers.set(code, { roomId, name, token, expiresAt }); return { code, expiresAt }; } @@ -806,7 +874,9 @@ let failWindowStart = 0; const FAIL_WINDOW_MS = 10 * 60 * 1000; const FAIL_LIMIT = 30; -export function claimTransferCode(code: string): { roomId: string; name: PlayerId; token: string } | { error: string } { +export function claimTransferCode( + code: string, +): { roomId: string; name: PlayerId; token: string; reseated: boolean; returned?: GameEvent } | { error: string } { const now = Date.now(); if (now - failWindowStart > FAIL_WINDOW_MS) { failWindowStart = now; @@ -824,8 +894,14 @@ export function claimTransferCode(code: string): { roomId: string; name: PlayerI } transfers.delete(normalized); // one-time const room = rooms.get(t.roomId); - if (!room || !tokenMatches(room, t.name, t.token)) return { error: "that seat no longer exists" }; - return { roomId: t.roomId, name: t.name, token: t.token }; + if (!room) return { error: "that seat no longer exists" }; + if (t.token === null) { + const fresh = reseat(room, t.name); + if ("error" in fresh) return { error: "that seat no longer exists" }; + return { roomId: t.roomId, name: t.name, token: fresh.token, reseated: true, ...(fresh.returned ? { returned: fresh.returned } : {}) }; + } + if (!tokenMatches(room, t.name, t.token)) return { error: "that seat no longer exists" }; + return { roomId: t.roomId, name: t.name, token: t.token, reseated: false }; } /** Rebuild one room from its ledger lines — the seed plus the log IS the @@ -861,7 +937,9 @@ function rebuildRoom(id: string, lines: RoomLine[]): Room | null { room.bots.delete(line.name); room.colorChoices.delete(line.name); } else if (line.kind === "reseat") { - if (room.tokens.has(line.name)) room.tokens.set(line.name, line.tokenHash); + if (personSeat(room, line.name)) seatToPerson(room, line.name, line.tokenHash); + } else if (line.kind === "standIn") { + seatToClockwork(room, line.name, line.by, line.style as AutomatonStyle, line.tier as AutomatonTier); } else if (line.kind === "join") { if (line.bot) { room.players.push(line.name); diff --git a/packages/server/src/store.ts b/packages/server/src/store.ts index b49b35e..97ab556 100644 --- a/packages/server/src/store.ts +++ b/packages/server/src/store.ts @@ -93,8 +93,9 @@ export interface KickLine { at: string; } -/** The keeper gave a person's seat a new token, for a player whose browser - * lost the old one; the old one opens nothing after. */ +/** A person's seat got a new token, from the keeper or from a phrase the + * host made, and the old one opens nothing after. A seat the clockwork was + * standing in for goes back to a person. */ export interface ReseatLine { kind: "reseat"; name: string; @@ -102,13 +103,25 @@ export interface ReseatLine { at: string; } +/** The host gave a person's seat to the clockwork in a game under way: the + * wizard keeps their hand and place, the automaton plays their turns, and + * the person's token opens nothing until a reseat gives the seat back. */ +export interface StandInLine { + kind: "standIn"; + name: string; + by: string; + style: string; + tier: string; + at: string; +} + export interface AbandonLine { kind: "abandon"; by: string; at: string; } -export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine | ChatLine | KickLine | ReseatLine | AbandonLine | RematchLine | ChallengeLine | GalleryTalkLine; +export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine | ChatLine | KickLine | ReseatLine | StandInLine | AbandonLine | RematchLine | ChallengeLine | GalleryTalkLine; const DATA_DIR = process.env.WIZWAR_DATA_DIR ?? join(process.cwd(), "data", "rooms"); diff --git a/packages/server/test/seats.test.ts b/packages/server/test/seats.test.ts new file mode 100644 index 0000000..f066cd6 --- /dev/null +++ b/packages/server/test/seats.test.ts @@ -0,0 +1,154 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { beforeAll, describe, expect, it, vi } from "vitest"; + +// The store reads its directory when first imported, so the rooms module is +// loaded only after the ledgers have somewhere of their own to go. +process.env.WIZWAR_DATA_DIR = join(mkdtempSync(join(tmpdir(), "wizwar-seats-")), "rooms"); + +let rooms: typeof import("../src/rooms"); +beforeAll(async () => { + rooms = await import("../src/rooms"); + rooms.loadPersistedRooms(); +}); + +/** Ann hosts, Bo and Cy sit, and the boards have flipped. */ +function playing() { + const { room, token: ann } = rooms.createRoom("Ann"); + const bo = token(rooms.joinRoom(room, "Bo", null)); + const cy = token(rooms.joinRoom(room, "Cy", null)); + const started = rooms.startGame(room, false); + if ("error" in started) throw new Error(started.error); + return { room, ann, bo, cy }; +} + +function token(result: { token: string } | { error: string }): string { + if ("error" in result) throw new Error(result.error); + return result.token; +} + +function phrase(result: { code: string } | { error: string }): string { + if ("error" in result) throw new Error(result.error); + return result.code; +} + +describe("a stand-in", () => { + it("plays a person's seat the host gives it, and the person's token opens nothing", () => { + const { room, bo } = playing(); + expect(rooms.standIn(room, "Ann", "Bo")).toEqual({ event: { type: "seatChanged", player: "Bo", to: "clockwork", by: "Ann" } }); + expect(room.bots.get("Bo")).toMatchObject({ standIn: true, secret: true }); + expect(rooms.joinRoom(room, "Bo", bo)).toEqual({ error: "the host gave this seat to the clockwork; ask them for a phrase to take it back" }); + expect(room.players).toContain("Bo"); + }); + + it("takes the turn when the seat it stands in for holds it, and leaves the host's to the host", () => { + const { room } = playing(); + rooms.standIn(room, "Ann", "Bo"); + rooms.standIn(room, "Ann", "Cy"); + const holder = room.state!.players[room.state!.turn.activeIndex]!.id; + expect(rooms.driveOneAutomaton(room)?.seat ?? null).toBe(holder === "Ann" ? null : holder); + }); + + it("is the host's call alone, in a game under way, for a person still in it", () => { + const { room } = playing(); + expect(rooms.standIn(room, "Bo", "Cy")).toEqual({ error: "only the host may give a seat to the clockwork" }); + expect(rooms.standIn(room, "Ann", "Ann")).toEqual({ error: "the host keeps their own seat" }); + expect(rooms.standIn(room, "Ann", "Dee")).toEqual({ error: "no person sits in that seat" }); + rooms.standIn(room, "Ann", "Bo"); + expect(rooms.standIn(room, "Ann", "Bo")).toEqual({ error: "no person sits in that seat" }); + + const { room: lobby } = rooms.createRoom("Ann"); + rooms.joinRoom(lobby, "Bo", null); + expect(rooms.standIn(lobby, "Ann", "Bo")).toEqual({ error: "only a game under way needs a stand-in" }); + }); + + it("is still standing in after a reload from the ledger", async () => { + const { room, bo } = playing(); + rooms.standIn(room, "Ann", "Bo"); + vi.resetModules(); + const reloaded: typeof import("../src/rooms") = await import("../src/rooms"); + const again = reloaded.getRoom(room.id)!; + expect(again).not.toBe(room); + expect(again.bots.get("Bo")).toEqual(room.bots.get("Bo")); + expect(again.events.filter((e) => e.type === "seatChanged")).toEqual([{ type: "seatChanged", player: "Bo", to: "clockwork", by: "Ann" }]); + expect("error" in reloaded.joinRoom(again, "Bo", bo)).toBe(true); + }); + it("holds the person's seat at a rematch, and brings no automaton for it", () => { + const { room } = playing(); + rooms.standIn(room, "Ann", "Bo"); + room.state = { ...room.state!, phase: "finished" }; + const called = rooms.callRematch(room, "Ann"); + if ("error" in called) throw new Error(called.error); + const next = rooms.getRoom(called.roomId)!; + expect(next.expected).toEqual(["Bo", "Cy"]); + expect(next.bots.size).toBe(0); + }); +}); + +describe("a phrase the host makes", () => { + it("re-seats a person whose browser lost the seat: the old token is refused, the new one opens it", () => { + const { room, bo } = playing(); + const claimed = rooms.claimTransferCode(phrase(rooms.makeSeatPhrase(room, "Ann", "Bo"))); + if ("error" in claimed) throw new Error(claimed.error); + expect(claimed).toMatchObject({ roomId: room.id, name: "Bo", reseated: true }); + expect(claimed.token).not.toBe(bo); + expect("error" in rooms.joinRoom(room, "Bo", bo)).toBe(true); + expect(rooms.joinRoom(room, "Bo", claimed.token)).toEqual({ token: claimed.token }); + }); + + it("takes a seat back from the clockwork", () => { + const { room } = playing(); + rooms.standIn(room, "Ann", "Bo"); + const claimed = rooms.claimTransferCode(phrase(rooms.makeSeatPhrase(room, "Ann", "Bo"))); + if ("error" in claimed) throw new Error(claimed.error); + expect(room.bots.has("Bo")).toBe(false); + expect(claimed.returned).toEqual({ type: "seatChanged", player: "Bo", to: "person" }); + expect(rooms.joinRoom(room, "Bo", claimed.token)).toEqual({ token: claimed.token }); + }); + + it("keeps the seat a person's again after a reload from the ledger", async () => { + const { room } = playing(); + rooms.standIn(room, "Ann", "Bo"); + const claimed = rooms.claimTransferCode(phrase(rooms.makeSeatPhrase(room, "Ann", "Bo"))); + if ("error" in claimed) throw new Error(claimed.error); + vi.resetModules(); + const reloaded: typeof import("../src/rooms") = await import("../src/rooms"); + const again = reloaded.getRoom(room.id)!; + expect(again.bots.has("Bo")).toBe(false); + expect(again.events.filter((e) => e.type === "seatChanged").map((e) => (e as { to: string }).to)).toEqual(["clockwork", "person"]); + expect(reloaded.joinRoom(again, "Bo", claimed.token)).toEqual({ token: claimed.token }); + }); + + it("is the host's to make, and only for a person's seat", () => { + const { room } = playing(); + expect(rooms.makeSeatPhrase(room, "Bo", "Cy")).toEqual({ error: "only the host may make a phrase for another seat" }); + expect(rooms.makeSeatPhrase(room, "Ann", "Dee")).toEqual({ error: "no person sits in that seat" }); + const { room: withBot } = rooms.createRoom("Ann"); + const bot = rooms.addAutomaton(withBot); + if ("error" in bot) throw new Error(bot.error); + expect(rooms.makeSeatPhrase(withBot, "Ann", bot.name)).toEqual({ error: "no person sits in that seat" }); + }); + + it("works once", () => { + const { room } = playing(); + const code = phrase(rooms.makeSeatPhrase(room, "Ann", "Cy")); + expect("error" in rooms.claimTransferCode(code)).toBe(false); + expect(rooms.claimTransferCode(code)).toEqual({ error: "that transfer phrase is unknown or has expired" }); + }); + + it("is voided when the host gives the seat to the clockwork after making it", () => { + const { room } = playing(); + const code = phrase(rooms.makeSeatPhrase(room, "Ann", "Bo")); + rooms.standIn(room, "Ann", "Bo"); + expect("error" in rooms.claimTransferCode(code)).toBe(true); + }); +}); + +describe("a player's own transfer phrase", () => { + it("still hands over the same token, without re-seating", () => { + const { room, bo } = playing(); + const claimed = rooms.claimTransferCode(phrase(rooms.makeTransferCode(room, "Bo", bo))); + expect(claimed).toEqual({ roomId: room.id, name: "Bo", token: bo, reseated: false }); + }); +}); diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index e48cb5b..1e35e36 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -32,6 +32,12 @@ const hall = $derived(kin.hall); let joinCode = $state(""); let claimPhrase = $state(""); + /** The host's seats panel: hand a seat to the clockwork, or make a phrase for it. */ + let seatsOpen = $state(false); + /** The seat the host asked to give the clockwork, waiting on a second click. */ + let standInAsked = $state(null); + /** The people's seats besides the host's own: the clockwork the host seated is not among them. */ + const peopleSeats = $derived(net.players.filter((p) => p !== net.you && (!net.roomBots[p] || net.standIns.includes(p)))); /** A /join/ recruiting link. The visitor lands looking at the * room: a held seat walks them back to it, a waiting room offers a * seat or the gallery, a started game is watched from the gallery. */ @@ -2030,6 +2036,10 @@ {#if !net.spectating} {/if} + {#if !net.spectating && net.you === net.hostId && peopleSeats.length > 0} + + {/if} @@ -2563,6 +2573,40 @@ {/if} + {#if seatsOpen && net.you === net.hostId && !net.spectating} +
+
+ Seats at your table + +
+ {#each peopleSeats as p (p)} + {@const standing = net.standIns.includes(p)} +
+ {p}{#if standing} ⚙ the clockwork plays for them{/if} + + {#if !standing && net.started && view?.phase === "playing" && view.players.find((w) => w.id === p)?.alive} + {#if standInAsked === p} + + + {:else} + + {/if} + {/if} +
+ {/each} +
+ {/if} + + {#if net.seatPhrase} +
+ Give {net.seatPhrase.name} this phrase to claim within ten minutes, on the front page or while watching this table. It works once, and their old link stops opening the seat. + {net.seatPhrase.code} + +
+ {/if} + {#if local.setup}
@@ -3272,7 +3316,7 @@ {#if view.phase === "finished"}
🏆 {view.winner} wins! {#if !local.active} - {@const otherHumans = net.players.some((p) => p !== net.you && !net.roomBots[p])} + {@const otherHumans = net.players.some((p) => p !== net.you && (!net.roomBots[p] || net.standIns.includes(p)))} {#if net.rematchCall} {#if net.rematchCall.by === net.you} @@ -3770,7 +3814,15 @@ {#if net.spectating} 👁 You watch from the Peanut Gallery, and every hand stays secret, even from you. {#if view.phase !== "finished"} - Had a seat at this table on another device? Choose transfer seat there and claim the phrase on the front page, or write to the keeper for a link that brings it back. + Had a seat at this table on another device? Choose transfer seat there, or ask {net.hostId ?? "the host"} for a phrase, and claim it here. Failing that, write to the keeper for a link that brings it back. + + + + {/if} {/if} {#each view.phase === "finished" ? [] : orderedHand as card (card.instanceId)} @@ -4366,6 +4418,29 @@ border-radius: 3px; user-select: all; } + .seats-slip { + width: min(34rem, 100%); + box-sizing: border-box; + margin: 0 auto 0.7rem; + display: flex; + flex-direction: column; + gap: 0.45rem; + } + .seats-head { + display: flex; + justify-content: space-between; + align-items: center; + font-weight: bold; + } + .seats-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + } + .seats-name { flex: 1 1 8rem; } + .seats-note { font-weight: normal; font-style: italic; opacity: 0.8; margin-left: 0.4em; } + .gallery-claim { margin-top: 0.4rem; } .claim-row { display: flex; flex-wrap: wrap; diff --git a/packages/web/src/net.svelte.ts b/packages/web/src/net.svelte.ts index e96b12d..b492139 100644 --- a/packages/web/src/net.svelte.ts +++ b/packages/web/src/net.svelte.ts @@ -223,6 +223,9 @@ export function humanize(e: GameEvent): string | null { case "objectPickedUp": return `${e.player} picks up the ${cardDef(e.card.cardId).name}, and their actions are over.`; case "chaosShielded": return `${e.player} raises a FULL SHIELD and sits out the chaos.`; case "tableTalk": return `\u{1F4AC} ${e.player}: ${e.text}`; + case "seatChanged": return e.to === "clockwork" + ? `⚙ ${e.by ?? "The host"} gives ${e.player}'s seat to the clockwork, which plays on in their place.` + : `${e.player} takes their seat back from the clockwork.`; case "dieRolled": return `\u{1F3B2} ${e.player ?? "The maze"} rolls a ${e.roll} (${e.purpose}).`; case "pushed": return e.player ? `${e.by} shoves ${e.player} down the corridor!` @@ -359,6 +362,10 @@ class Net { * socket that never fires onclose; the watchdog closes it by hand so * the reconnect loop (and the masthead banner) actually engage. */ private lastHeard = Date.now(); + /** When the watchdog's last ping went out. A socket is dead when that + * ping went unanswered, not when it has been quiet a while: a hidden + * tab's timers may run only once a minute, so quiet proves nothing. */ + private pingSentAt: number | null = null; /** Whose turn the chronicle is in, for the mark on lines with no actor of their own. */ private turnOwner: string | null = null; private watchdog: ReturnType | null = null; @@ -369,6 +376,10 @@ class Net { /** Lobby standee choices, by player name. */ roomColors = $state>({}); roomBots = $state>({}); + /** People's seats the clockwork plays for now, at the host's word. */ + standIns = $state([]); + /** A phrase the host made for another wizard's seat, to pass to them. */ + seatPhrase = $state<{ name: string; code: string; expiresAt: number } | null>(null); you = $state(null); /** Seated in the Peanut Gallery: watching nameless, read-only. */ spectating = $state(false); @@ -462,11 +473,16 @@ class Net { const ws = new WebSocket(SERVER_URL); this.ws = ws; this.lastHeard = Date.now(); + this.pingSentAt = null; if (!this.watchdog) { this.watchdog = setInterval(() => { if (!this.ws || this.status !== "connected") return; + if (this.pingSentAt !== null && this.lastHeard < this.pingSentAt) { + this.ws.close(); + return; + } + this.pingSentAt = Date.now(); this.send({ type: "ping" }); - if (Date.now() - this.lastHeard > 45_000) this.ws.close(); }, 15_000); } ws.onopen = () => { @@ -558,6 +574,7 @@ class Net { this.roomId = msg.roomId; this.roomColors = msg.colors ?? {}; this.roomBots = msg.bots ?? {}; + this.standIns = msg.standIns ?? []; this.audience = msg.audience ?? 0; this.rematchCall = msg.rematch ?? null; this.expected = msg.expected ?? []; @@ -652,6 +669,13 @@ class Net { this.log = [...this.log, { text: `— the host cleared your seat in ${msg.roomId} —`, turn: null, notable: false }]; this.leaveLocal(); break; + case "standingIn": + this.log = [...this.log, { text: `— the host gave your seat in ${msg.roomId} to the clockwork —`, turn: null, notable: false }]; + this.leaveLocal(); + break; + case "seatPhrase": + this.seatPhrase = { name: msg.name, code: msg.code, expiresAt: msg.expiresAt }; + break; case "roomAbandoned": this.log = [...this.log, { text: `— the host closed room ${msg.roomId} —`, turn: null, notable: false }]; this.leaveLocal(); @@ -862,6 +886,16 @@ class Net { this.send({ type: "kickSeat", name }); } + /** Host only: the clockwork plays this person's seat from here. */ + standIn(name: string): void { + this.send({ type: "standIn", name }); + } + + /** Host only: a phrase that brings this person's seat to whoever claims it. */ + makeSeatPhrase(name: string): void { + this.send({ type: "makeSeatPhrase", name }); + } + abandonRoom(): void { this.send({ type: "abandonRoom" }); }