The keeper can bring back a seat a browser lost: a reseat line on the ledger gives that wizard's seat a new token and the old one opens nothing after, wizwar-reseat.sh on the host asks for it through a keeper door that listens on the loopback alone and opens only to a key the deploy makes once, and the link it prints carries the seat in the fragment, which leaves the address bar at once and is kept only when no seat held in this browser still opens and the server takes it; a gallery watching a game in play is told both ways back, the front page asks before forgetting a game not yet finished, and the server's tests now run with the rest

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MbtUPJ24P6vc6tE1x6RS3m
This commit is contained in:
Eric Wagoner
2026-10-13 18:33:04 -04:00
co-authored by Claude Opus 5.5
parent 1b9acfd7c4
commit 5ea0d4fdec
16 changed files with 423 additions and 16 deletions
+2 -2
View File
@@ -1021,7 +1021,7 @@ describe("interception, escape, and hazard sense", () => {
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
expect(cmd).toMatchObject({ type: "cast", instanceId: "DC", target: { kind: "cell" } });
const r = applyCommand(state, "bot", cmd!);
expect(r.ok).toBe(true);
if (!r.ok) throw new Error(r.error);
// The box gone, the very next thought is the grab.
const next = automatonCommand(viewFor(r.state, "bot"), "hunter", "archmage");
expect(next).toMatchObject({ type: "pickUpTreasure" });
@@ -1050,7 +1050,7 @@ describe("interception, escape, and hazard sense", () => {
cmd = automatonFallback(view, "archmage");
r = applyCommand(state, seat, cmd);
}
expect(r.ok).toBe(true);
if (!r.ok) throw new Error(`${seat}: ${r.error}`);
state = r.state;
ended = seat === "bot" && cmd.type === "endTurn";
}
+4 -2
View File
@@ -5,7 +5,8 @@
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@sentry/node": "^10.73.0",
@@ -16,6 +17,7 @@
"@types/node": "^22.0.0",
"@types/ws": "^8.5.0",
"tsx": "^4.19.0",
"typescript": "^5.6.0"
"typescript": "^5.6.0",
"vitest": "^2.1.0"
}
}
+14
View File
@@ -81,6 +81,7 @@ import {
import { engagementStats, recordHotseat } from "./stats";
import { appendFeedback, readFeedback, readClips, clipAssetPath, CLIP_SLUG, feedbackImageDir } from "./store";
import { clipsIndexHtml, clipPageHtml } from "./clips";
import { openKeeperDoor } from "./keeper";
import { SlidingLimit, clientAddress } from "./ratelimit";
import { getShare, loadShares, mintShare } from "./shares";
import { renderSharePng } from "./ogimage";
@@ -535,6 +536,19 @@ const httpServer = createServer((req, res) => {
const wss = new WebSocketServer({ server: httpServer, maxPayload: 64 * 1024 });
httpServer.listen(port, host);
// The keeper's door listens on the loopback alone, on a port of its own, and
// opens only to the key in KEEPER_KEY_FILE (deploy/wizwar-reseat.sh). A
// socket still seated on the old token is closed: its reconnect offers that
// token, and it opens nothing now.
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();
},
});
interface Session {
socket: WebSocket;
playerId: PlayerId | null;
+83
View File
@@ -0,0 +1,83 @@
// The keeper's door: a listener of its own on the loopback, apart from the
// port Caddy forwards to, so it answers only on the host itself, and only to
// the key in its key file, so another service on a shared host cannot use
// it. Its one caller is deploy/wizwar-reseat.sh, which sends the key:
// GET /keeper/rooms/:id who sits at the table, and when each person last moved or spoke
// POST /keeper/rooms/:id/reseat {seat} a new token for a player locked out, as a link to send them
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 { getRoom, reseat, seatsForKeeper, type Room } from "./rooms";
function send(res: ServerResponse, status: number, body: unknown): void {
res.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(body));
}
/** The key is read on each knock, so a key written after the server started is honoured. Absent, the door stays shut. */
function keyGiven(req: IncomingMessage, keyFile: string): boolean {
let key: Buffer;
try {
key = Buffer.from(readFileSync(keyFile, "utf8").trim());
} catch {
return false;
}
const given = Buffer.from(String(req.headers["x-keeper-key"] ?? ""));
return key.length > 0 && given.length === key.length && timingSafeEqual(given, key);
}
async function readSeat(req: IncomingMessage): Promise<string> {
let raw = "";
for await (const chunk of req) {
raw += chunk;
if (raw.length > 4096) return "";
}
try {
const seat = (JSON.parse(raw) as { seat?: unknown }).seat;
return typeof seat === "string" ? seat : "";
} catch {
return "";
}
}
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;
}
async function handle(req: IncomingMessage, res: ServerResponse, { publicUrl, keyFile, onReseat }: KeeperDoor): Promise<void> {
const parts = (req.url ?? "/").split("?")[0]!.split("/").filter(Boolean);
// Nothing proxied belongs here: a forwarded request is turned away even if a proxy is someday pointed at this port.
if (req.headers["x-forwarded-for"] || !keyGiven(req, keyFile)) return send(res, 404, { error: "not here" });
if (parts[0] !== "keeper" || parts[1] !== "rooms" || !parts[2]) return send(res, 404, { error: "not here" });
const room = getRoom(parts[2]);
if (!room) return send(res, 404, { error: "no such room" });
if (parts.length === 3 && req.method === "GET") {
return send(res, 200, { roomId: room.id, started: room.state !== null, finished: room.state?.phase === "finished", seats: seatsForKeeper(room) });
}
if (parts.length === 4 && parts[3] === "reseat" && req.method === "POST") {
const name = await readSeat(req);
const result = reseat(room, name);
if ("error" in result) return send(res, 404, result);
onReseat(room, name);
// 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}` });
}
return send(res, 404, { error: "not here" });
}
/** Open the door on 127.0.0.1, whatever address the game itself listens on. */
export function openKeeperDoor(door: KeeperDoor): Server {
const server = createServer((req, res) => {
handle(req, res, door).catch((e) => {
console.error("keeper door:", e);
if (!res.headersSent) send(res, 500, { error: "internal error" });
});
});
server.listen(door.port, "127.0.0.1");
return server;
}
+27
View File
@@ -331,6 +331,31 @@ 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" };
const token = randomBytes(16).toString("hex");
room.tokens.set(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 };
}
/** The table as the keeper sees it before re-seating: who sits where, and
* when each person last moved or spoke. */
export function seatsForKeeper(room: Room): { name: PlayerId; host: boolean; bot: boolean; lastActed: string | null }[] {
const last = new Map<PlayerId, string>();
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);
return { name, host: name === room.hostId, bot, lastActed: bot ? null : (last.get(name) ?? 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 {
@@ -835,6 +860,8 @@ function rebuildRoom(id: string, lines: RoomLine[]): Room | null {
room.tokens.delete(line.name);
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);
} else if (line.kind === "join") {
if (line.bot) {
room.players.push(line.name);
+10 -1
View File
@@ -93,13 +93,22 @@ 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. */
export interface ReseatLine {
kind: "reseat";
name: string;
tokenHash: string;
at: string;
}
export interface AbandonLine {
kind: "abandon";
by: string;
at: string;
}
export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine | ChatLine | KickLine | AbandonLine | RematchLine | ChallengeLine | GalleryTalkLine;
export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine | ChatLine | KickLine | ReseatLine | AbandonLine | RematchLine | ChallengeLine | GalleryTalkLine;
const DATA_DIR = process.env.WIZWAR_DATA_DIR ?? join(process.cwd(), "data", "rooms");
+151
View File
@@ -0,0 +1,151 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { once } from "node:events";
import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, 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.
const scratch = mkdtempSync(join(tmpdir(), "wizwar-keeper-"));
process.env.WIZWAR_DATA_DIR = join(scratch, "rooms");
const KEY_FILE = join(scratch, "keeper.key");
const KEY = "a".repeat(64);
let rooms: typeof import("../src/rooms");
let keeper: typeof import("../src/keeper");
beforeAll(async () => {
rooms = await import("../src/rooms");
rooms.loadPersistedRooms();
keeper = await import("../src/keeper");
});
function table() {
const { room, token: ann } = rooms.createRoom("Ann");
const joined = rooms.joinRoom(room, "Bo", null);
if ("error" in joined) throw new Error(joined.error);
const bot = rooms.addAutomaton(room);
if ("error" in bot) throw new Error(bot.error);
return { room, ann, bo: joined.token, bot: bot.name };
}
function token(result: { token: string } | { error: string }): string {
if ("error" in result) throw new Error(result.error);
return result.token;
}
describe("the keeper's reseat", () => {
it("gives a person's seat a new token: the old one is refused and the new one opens the seat", () => {
const { room, bo } = table();
const fresh = token(rooms.reseat(room, "Bo"));
expect(fresh).not.toBe(bo);
expect(rooms.joinRoom(room, "Bo", bo)).toEqual({ error: "that wizard name is taken in this room" });
expect(rooms.joinRoom(room, "Bo", fresh)).toEqual({ token: fresh });
expect(rooms.peekSummary(room.id, "Bo", bo)).toBe("badToken");
});
it("re-seats the host the same way", () => {
const { room, ann } = table();
const fresh = token(rooms.reseat(room, "Ann"));
expect("error" in rooms.joinRoom(room, "Ann", ann)).toBe(true);
expect(rooms.joinRoom(room, "Ann", fresh)).toEqual({ token: fresh });
});
it("voids a transfer phrase minted with the old token", () => {
const { room, bo } = table();
const minted = rooms.makeTransferCode(room, "Bo", bo);
if ("error" in minted) throw new Error(minted.error);
rooms.reseat(room, "Bo");
expect("error" in rooms.claimTransferCode(minted.code)).toBe(true);
});
it("keeps the new token across a reload from the ledger", async () => {
const { room, ann, bo } = table();
const fresh = token(rooms.reseat(room, "Bo"));
vi.resetModules();
const reloaded: typeof import("../src/rooms") = await import("../src/rooms");
const again = reloaded.getRoom(room.id);
expect(again).toBeDefined();
expect(again).not.toBe(room);
expect(reloaded.joinRoom(again!, "Bo", bo)).toEqual({ error: "that wizard name is taken in this room" });
expect(reloaded.joinRoom(again!, "Bo", fresh)).toEqual({ token: fresh });
expect(reloaded.joinRoom(again!, "Ann", ann)).toEqual({ token: ann });
});
it("re-seats only a person who sits at the table", () => {
const { room, bot } = table();
expect(rooms.reseat(room, bot)).toEqual({ error: "no person sits in that seat" });
expect(rooms.reseat(room, "Cy")).toEqual({ error: "no person sits in that seat" });
});
it("tells the keeper who sits where, and when each person last moved or spoke", () => {
const { room, bot } = table();
rooms.addChat(room, "Bo", "good luck");
const seats = rooms.seatsForKeeper(room);
expect(seats.map((s) => [s.name, s.bot, s.host])).toEqual([["Ann", false, true], ["Bo", false, false], [bot, true, false]]);
expect(seats[0]!.lastActed).toBeNull();
expect(seats[1]!.lastActed).toMatch(/^\d{4}-/);
expect(seats[2]!.lastActed).toBeNull();
});
});
describe("the keeper's door", () => {
let door: import("node:http").Server;
let base: string;
const knock = (path: string, init: RequestInit = {}) =>
fetch(`${base}${path}`, { ...init, headers: { "x-keeper-key": KEY, ...(init.headers as Record<string, string>) } });
beforeAll(async () => {
writeFileSync(KEY_FILE, KEY + "\n");
door = keeper.openKeeperDoor({ port: 0, publicUrl: "https://wizwar.example", keyFile: KEY_FILE, onReseat: () => {} });
await once(door, "listening");
const address = door.address() as AddressInfo;
base = `http://127.0.0.1:${address.port}`;
});
afterAll(() => door.close());
it("listens on the loopback alone", () => {
expect((door.address() as AddressInfo).address).toBe("127.0.0.1");
});
it("lists a table's seats and re-seats one as a link carrying the token in the fragment", async () => {
const { room, bo } = table();
const listed = await (await knock(`/keeper/rooms/${room.id}`)).json();
expect(listed.seats.map((s: { name: string }) => s.name)).toContain("Bo");
const res = await knock(`/keeper/rooms/${room.id.toLowerCase()}/reseat`, {
method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ seat: "Bo" }),
});
expect(res.status).toBe(200);
const { name, link } = await res.json();
expect(name).toBe("Bo");
const m = new RegExp(`^https://wizwar\\.example/join/${room.id}#seat=Bo\\.([0-9a-f]{32})$`).exec(link);
expect(m).not.toBeNull();
expect("error" in rooms.joinRoom(room, "Bo", bo)).toBe(true);
expect(rooms.joinRoom(room, "Bo", m![1]!)).toEqual({ token: m![1] });
});
it("turns away a forwarded request, an unknown room and a bot", async () => {
const { room, bot } = table();
expect((await knock(`/keeper/rooms/${room.id}`, { headers: { "x-forwarded-for": "203.0.113.9" } })).status).toBe(404);
expect((await knock(`/keeper/rooms/${room.id}X`)).status).toBe(404);
const res = await knock(`/keeper/rooms/${room.id}/reseat`, { method: "POST", body: JSON.stringify({ seat: bot }) });
expect(res.status).toBe(404);
expect((await res.json()).error).toBe("no person sits in that seat");
});
it("opens only to the keeper's key, and stays shut while the key file is absent", async () => {
const { room, bo } = table();
const reseatBo = (headers: Record<string, string>) =>
fetch(`${base}/keeper/rooms/${room.id}/reseat`, { method: "POST", headers, body: JSON.stringify({ seat: "Bo" }) });
expect((await reseatBo({})).status).toBe(404);
expect((await reseatBo({ "x-keeper-key": "b".repeat(64) })).status).toBe(404);
expect((await reseatBo({ "x-keeper-key": KEY.slice(1) })).status).toBe(404);
rmSync(KEY_FILE);
try {
expect((await reseatBo({ "x-keeper-key": KEY })).status).toBe(404);
expect((await reseatBo({ "x-keeper-key": "" })).status).toBe(404);
} finally {
writeFileSync(KEY_FILE, KEY + "\n");
}
expect(rooms.joinRoom(room, "Bo", bo)).toEqual({ token: bo });
});
});
+1 -1
View File
@@ -4,5 +4,5 @@
"noEmit": true,
"types": ["node"]
},
"include": ["src"]
"include": ["src", "test"]
}
+32 -6
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { motion } from "./motion";
import { attentionLabel, net, SIDE_NAMES, spellName } from "./net.svelte";
import { attentionLabel, net, seatInLink, SIDE_NAMES, spellName } from "./net.svelte";
import { MediaQuery } from "svelte/reactivity";
import { FISTS } from "./fpv/paintedFx";
import Board from "./Board.svelte";
@@ -37,14 +37,29 @@
* seat or the gallery, a started game is watched from the gallery. */
const inviteCode = location.pathname.match(/^\/join\/([A-Za-z0-9]{4})$/)?.[1]?.toUpperCase() ?? null;
if (inviteCode) joinCode = inviteCode;
/** A link from the keeper carries a seat in its fragment. It leaves the
* address bar at once, is tried only when no seat held here still opens,
* and is kept only once the server takes it, so a link someone else
* sends cannot take a working seat away. */
const linkedSeat = inviteCode ? seatInLink(location.hash, inviteCode) : null;
if (location.hash.startsWith("#seat=")) history.replaceState(history.state, "", location.pathname + location.search);
let inviteFollowed = false;
$effect(() => {
if (!inviteCode || inviteFollowed || net.status !== "connected" || net.roomId) return;
inviteFollowed = true;
const seat = net.seats.find((s) => s.roomId === inviteCode);
if (seat) net.resume(seat);
else net.watch(inviteCode);
const code = inviteCode;
const fromLink = linkedSeat && (() => net.resume(linkedSeat, () => {
net.watch(code);
net.flash("The keeper's link no longer opens that seat. Write again for a fresh one.");
}));
const seat = net.seats.find((s) => s.roomId === code);
if (seat) net.resume(seat, fromLink || undefined);
else if (fromLink) fromLink();
else net.watch(code);
});
/** A game not yet finished asks before it is forgotten: this browser may
* be all that holds the seat. */
let forgetting = $state<string | null>(null);
/** The address bar holds the table's invite link while you sit at one,
* so recruiting is a copy away. */
$effect(() => {
@@ -2701,8 +2716,15 @@
<button class="stamp tiny" title="take your seat at the rematch"
onclick={() => { net.you = seat.name; net.spectating = false; net.join(g.rematch!.roomId, seat.name); }}>rematch</button>
{/if}
<button class="ledger-forget" title="forget this game"
onclick={() => net.forgetSeat(seat.roomId)}>×</button>
{#if forgetting === seat.roomId}
<span class="forget-ask">This browser may be all that holds your seat.
<button class="hint-cancel" onclick={() => { forgetting = null; net.forgetSeat(seat.roomId); }}>forget it</button>
<button class="hint-cancel" onclick={() => (forgetting = null)}>keep it</button>
</span>
{:else}
<button class="ledger-forget" title="forget this game"
onclick={() => { if (g?.finished) net.forgetSeat(seat.roomId); else forgetting = seat.roomId; }}>×</button>
{/if}
</div>
{/each}
{#if net.feedbackReports.length > 0}
@@ -3731,6 +3753,9 @@
<div class="hand" bind:this={handEl} role="list" class:spent={actionsSpent && !discardMode && discardSelection.size === 0} aria-label="your hand">
{#if net.spectating}
<span class="gallery-note">👁 You watch from the Peanut Gallery, and every hand stays secret, even from you.</span>
{#if view.phase !== "finished"}
<span class="gallery-note">Had a seat at this table on another device? Choose <em>transfer seat</em> there and claim the phrase on the front page, or <a href="mailto:eric@ericwagoner.com?subject={encodeURIComponent(`My seat in room ${net.roomId}`)}">write to the keeper</a> for a link that brings it back.</span>
{/if}
{/if}
{#each view.phase === "finished" ? [] : orderedHand as card (card.instanceId)}
<div class="hand-slot"
@@ -4399,6 +4424,7 @@
padding: 0 0.3rem;
}
.ledger-forget:hover { color: #b3372b; }
.forget-ask { font-size: 0.8rem; color: #8d8672; flex: none; }
.stamp {
font-family: "Oswald", sans-serif;
+6
View File
@@ -306,6 +306,12 @@
whatever name you like, and know that what you say and do at an
online table is part of its record.
</p>
<p>
Your seat at an online game is kept in this browser. To carry it to
another device, choose <em>transfer seat</em> at the table and claim
the phrase there. If a browser loses it, write to the keeper for a
link that brings it back.
</p>
<h3>Send word</h3>
<p>
Feedback, bug reports and faint praise are all welcome. Ravens fly to
+33 -3
View File
@@ -297,6 +297,15 @@ function loadSeen(): Record<string, number> {
export interface Seat { name: string; roomId: string; token: string }
/** A seat the keeper sent in a link, `#seat=<name>.<token>`: a fragment
* stays in the browser, out of every log. */
export function seatInLink(hash: string, roomId: string): Seat | null {
const m = /^#seat=(.+)\.([0-9a-f]{32})$/.exec(hash);
if (!m) return null;
try { return { name: decodeURIComponent(m[1]!), roomId, token: m[2]! }; }
catch { return null; }
}
export interface FeedbackReportView {
id: string;
at: string;
@@ -445,6 +454,8 @@ class Net {
private ws: WebSocket | null = null;
private token: string | null = null;
private roomIdPending: string | null = null;
/** Where a resume turns when the server refuses its seat. */
private onRefused: (() => void) | null = null;
connect(): void {
if (this.ws) return;
@@ -483,6 +494,8 @@ class Net {
ws.onclose = () => {
this.status = "disconnected";
this.ws = null;
// A dropped socket proves nothing about a seat: no fallback outlives it.
this.onRefused = null;
// Whatever was in flight is now in doubt: the board will say what
// landed when the connection returns, and the talk goes back in the box.
if (this.pending) { this.unconfirmed = this.pending.label; this.pending = null; }
@@ -524,6 +537,7 @@ class Net {
case "pong":
break;
case "seat": {
this.onRefused = null;
this.token = msg.token;
const seatRoom = (this.roomIdPending ?? this.roomId ?? "").toUpperCase();
if (seatRoom) {
@@ -715,7 +729,20 @@ class Net {
}
break;
}
case "error":
case "error": {
// A refused seat with somewhere else to turn takes that turn, with
// no toast for a door that was only tried. Only the server's word
// that the token is wrong counts as a refusal; any other error
// disarms the fallback.
const refused = this.onRefused;
this.onRefused = null;
if (refused && this.roomIdPending && /name is taken/.test(msg.message)) {
localStorage.removeItem(SEAT_KEY);
this.roomIdPending = null;
this.token = null;
refused();
break;
}
// "Name is taken" means THIS token was tried and refused: the
// credential itself is dead, and holding it helps nobody. But
// "no such room" proves nothing about the seat — a restarting
@@ -734,6 +761,7 @@ class Net {
this.log = [...this.log, { text: `— ${msg.message} —`, turn: null, notable: false }];
setTimeout(() => { if (this.error === msg.message) this.error = null; }, 5000);
break;
}
}
}
@@ -775,8 +803,10 @@ class Net {
}
}
/** Sit back down at a remembered seat. */
resume(seat: Seat): void {
/** Sit back down at a seat. It is remembered only once the server
* takes it; `refused` runs if the server turns it away. */
resume(seat: Seat, refused?: () => void): void {
this.onRefused = refused ?? null;
this.you = seat.name;
this.spectating = false;
this.token = seat.token;