The host has a hand on the other seats: once the duel begins, Seats makes a transfer phrase for another wizard's seat, which gives it a new token when claimed, and gives the seat of a wizard who cannot go on to a bot, which duels the turn it owes at once and keeps gestures already sealed; the wizard's token opens nothing while it stands in, their page goes to the gallery with the reason and a claim box, the roster says a bot duels for them, and a rematch holds their seat without the stand-in; the server pings every socket and closes one a dead browser left open

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
This commit is contained in:
Eric WagonerandClaude Opus 5.5 committed 2026-10-16 19:04:33 -04:00
1 parent a673ea9766
commit f98bfba107
14 files changed
+438 -31

No files matched your search

+31
View File
@@ -14,6 +14,8 @@
// POST /api/rooms/:id/challenge {token} call the keeper of the site to a held seat; their phone rings
// POST /api/rooms/:id/rematch {token} a finished table opens another with the same company
// POST /api/rooms/:id/transfer {token} a four-word phrase that carries this seat to another device
// POST /api/rooms/:id/phrase {token, seat} the host mints one for another wizard's seat, which re-seats it when claimed
// POST /api/rooms/:id/standin {token, seat} the host gives a wizard's seat to a bot for the rest of the duel
// POST /api/transfer {phrase} claim a phrase: the seat and its token, once
// WS /ws?room=:id&token= {type:"update", seq} whenever the room changes; without a token,
// a seat in the Peanut Gallery, counted for the table
@@ -79,6 +81,8 @@ const bells = new RateLimit(3, 60 * 60 * 1000);
/** Claims on transfer phrases: a phrase is guessed, not brute-forced. */
const claims = new RateLimit(10, 10 * 60 * 1000);
const MAX_AUDIENCE = 30;
/** How often each socket is asked to answer; one silent through a whole round is a browser gone, and is closed. */
const PING_MS = Number(process.env.PING_MS ?? 30_000);
const IDLE_ROOM_MS = 7 * 24 * 60 * 60 * 1000;
setInterval(() => {
const n = rooms.evictIdle(IDLE_ROOM_MS);
@@ -339,6 +343,14 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
if (action === 'transfer') {
return send(res, 200, rooms.transfer(room, typeof body.token === 'string' ? body.token : undefined));
}
if (action === 'phrase') {
return send(res, 200, rooms.hostPhrase(room, typeof body.token === 'string' ? body.token : undefined, body.seat));
}
if (action === 'standin') {
const token = typeof body.token === 'string' ? body.token : undefined;
rooms.standIn(room, token, body.seat);
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
}
throw new RoomError('Not here.', 404);
}
@@ -372,12 +384,31 @@ server.on('upgrade', (req, socket, head) => {
};
const unsubscribe = seatId === SPECTATOR ? rooms.watch(room, onChange) : rooms.subscribe(room.id, onChange);
ws.on('close', unsubscribe);
answered.set(ws, true);
ws.on('pong', () => answered.set(ws, true));
// A socket's own error (a message over the limit, a malformed frame) closes that socket, never the server.
ws.on('error', () => ws.terminate());
ws.send(JSON.stringify({ type: 'hello', seat: seatId }));
});
});
/**
* A browser that died without closing (a crashed tab, a sleeping laptop, a dropped network) leaves its socket
* open until TCP gives up, counted in the gallery all the while. The protocol's own ping, which every browser
* answers by itself, finds it within two rounds.
*/
const answered = new WeakMap<WebSocket, boolean>();
setInterval(() => {
for (const ws of wss.clients) {
if (!answered.get(ws)) {
ws.terminate();
continue;
}
answered.set(ws, false);
ws.ping();
}
}, PING_MS).unref();
server.listen(PORT, HOST, () => {
console.log(`waving-hands server listening on http://${HOST}:${PORT}, rooms in ${DATA_DIR}`);
});
+64
View File
@@ -3,6 +3,7 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { Rooms } from './rooms';
import { chooseBotTurn } from '../../src/lib/game/bot';
import { Store } from './store';
let dir: string;
@@ -47,3 +48,66 @@ describe('rooms', () => {
expect(seats[1].lastActed).toBeNull();
});
});
describe('the host and the other seats', () => {
it("gives a wizard's seat to a bot, which duels the turn it was waiting on, and the wizard's token opens nothing", () => {
const { rooms, room, ann, bo } = table();
rooms.submit(room, ann, chooseBotTurn(room.state!, 'A'), 0);
rooms.standIn(room, ann, 'B');
expect(room.resolved).toBe(1);
expect(() => rooms.seatOf(room, bo)).toThrow(/gave your seat to a bot/);
expect(rooms.view(room, 'A').seats[1]).toEqual({ id: 'B', name: 'Bo', bot: true, standIn: true });
});
it('keeps the gestures a wizard made before the bot stood in', () => {
const { rooms, room, ann, bo } = table();
const mine = { left: 'P', right: 'W', casts: [] } as const;
rooms.submit(room, bo, mine, 0);
rooms.standIn(room, ann, 'B');
rooms.submit(room, ann, chooseBotTurn(room.state!, 'A'), 0);
expect(room.state!.wizards.B.history.at(-1)).toMatchObject({ left: 'P', right: 'W' });
});
it("is the host's call alone, in a duel under way, for a wizard other than the host", () => {
const { rooms, room, ann, bo } = table();
expect(() => rooms.standIn(room, bo, 'A')).toThrow(/Only the wizard who opened/);
expect(() => rooms.standIn(room, ann, 'A')).toThrow(/keeps their own seat/);
expect(() => rooms.standIn(room, ann, 'Z')).toThrow(/No wizard sits/);
const lobby = rooms.create('Ann', 2);
rooms.join(lobby.room, 'Bo');
expect(() => rooms.standIn(lobby.room, lobby.token, 'B')).toThrow(/under way/);
});
it("gives the seat back by the host's phrase, with a new token, and a restart keeps both changes", () => {
const { rooms, room, ann, bo } = table();
rooms.standIn(room, ann, 'B');
expect(new Rooms(new Store(dir)).get(room.id).seats[1]).toMatchObject({ bot: true, standIn: true });
const claimed = rooms.claim(rooms.hostPhrase(room, ann, 'B').phrase);
expect(rooms.seatOf(room, claimed.token).name).toBe('Bo');
expect(() => rooms.seatOf(room, bo)).toThrow(/opens no seat/);
const reopened = new Rooms(new Store(dir)).get(room.id);
expect(reopened.seats[1]).toEqual({ id: 'B', name: 'Bo', tokenHash: room.seats[1].tokenHash, bot: false });
});
it("re-seats a wizard whose browser lost the seat, by the host's phrase, once, and only the host makes one", () => {
const { rooms, room, ann, bo } = table();
expect(() => rooms.hostPhrase(room, bo, 'A')).toThrow(/Only the wizard who opened/);
expect(() => rooms.hostPhrase(room, ann, 'A')).toThrow(/No other wizard/);
const { phrase } = rooms.hostPhrase(room, ann, 'B');
expect(rooms.claim(phrase).token).not.toBe(bo);
expect(() => rooms.seatOf(room, bo)).toThrow(/opens no seat/);
expect(() => rooms.claim(phrase)).toThrow(/unknown or has expired/);
});
it('voids a phrase for the seat once a bot stands in, and holds the wizard\'s seat at a rematch without the stand-in', () => {
const { rooms, room, ann } = table();
const { phrase } = rooms.hostPhrase(room, ann, 'B');
rooms.standIn(room, ann, 'B');
expect(() => rooms.claim(phrase)).toThrow(/unknown or has expired/);
room.state = { ...room.state!, over: { winner: 'A', reason: 'test' } } as typeof room.state;
const next = rooms.get(rooms.rematch(room, ann).roomId);
expect(next.expected).toEqual(['Bo']);
expect(next.seats.map((s) => s.name)).toEqual(['Ann']);
});
});
+75 -17
View File
@@ -23,9 +23,12 @@ const CHAT_KEEP = 200;
export interface Seat {
id: WizardId;
name: string;
/** SHA-256 of the seat's token, hex; empty for a bot. */
/** SHA-256 of the seat's token, hex; empty for a bot. A seat a bot stands in for keeps the wizard's, which opens nothing while it does. */
tokenHash: string;
/** A bot plays this seat: one seated as a bot, or one standing in for a wizard. */
bot: boolean;
/** The host gave this wizard's seat to a bot; a phrase from the host, or the keeper, gives it back. */
standIn?: true;
}
export function hashToken(token: string): string {
@@ -153,7 +156,8 @@ export class Rooms {
seatOf(room: Room, token: string | undefined): Seat {
if (!token) throw new RoomError('That token opens no seat at this duel.', 403);
const given = Buffer.from(hashToken(token), 'hex');
const seat = room.seats.find((s) => !s.bot && s.tokenHash.length === given.length * 2 && timingSafeEqual(Buffer.from(s.tokenHash, 'hex'), given));
const seat = room.seats.find((s) => s.tokenHash.length === given.length * 2 && timingSafeEqual(Buffer.from(s.tokenHash, 'hex'), given));
if (seat?.standIn) throw new RoomError('The host gave your seat to a bot, which duels on in your place. Ask them for a transfer phrase to take it back.', 403);
if (!seat) throw new RoomError('That token opens no seat at this duel.', 403);
return seat;
}
@@ -223,21 +227,38 @@ export class Rooms {
const caller = this.seatOf(room, token);
if (!room.state?.over) throw new RoomError('The duel is not over yet.', 409);
if (room.rematch) return { roomId: room.rematch.to, seat: null, token: null, created: false };
const others = room.seats.filter((s) => !s.bot && s.id !== caller.id).map((s) => s.name);
// A wizard a bot stood in for is still one of the company; the stand-in does not follow.
const others = room.seats.filter((s) => (!s.bot || s.standIn) && s.id !== caller.id).map((s) => s.name);
const next = this.open(caller.name, room.size, { rematchOf: room.id, expected: others });
for (const bot of room.seats.filter((s) => s.bot)) this.sit(next.room, bot.name, true);
for (const bot of room.seats.filter((s) => s.bot && !s.standIn)) this.sit(next.room, bot.name, true);
this.commit(room, { t: 'rematch', at: Date.now(), to: next.room.id, by: caller.id });
return { roomId: next.room.id, seat: next.seat, token: next.token, created: true };
}
/** Phrases waiting to be claimed, with the seat and the token they carry. */
private transfers = new Map<string, { roomId: string; seatId: WizardId; token: string; expiresAt: number }>();
/** Phrases waiting to be claimed, with the seat and the token they carry; the host's carry none, and re-seat on claim. */
private transfers = new Map<string, { roomId: string; seatId: WizardId; token: string | null; expiresAt: number }>();
private failedClaims = 0;
private failWindowStart = 0;
/** Mint a phrase that brings this seat to another device: four words, ten minutes, one use. */
transfer(room: Room, token: string | undefined): { phrase: string; expiresAt: number } {
const seat = this.seatOf(room, token);
return this.mint(room.id, seat.id, token!);
}
/**
* The host mints a phrase for another wizard's seat: for a player whose browser lost it, or to take it back
* from a bot standing in. Claimed, it gives the seat a new token, so the old one opens nothing after.
*/
hostPhrase(room: Room, token: string | undefined, seatId: unknown): { phrase: string; expiresAt: number } {
const host = this.seatOf(room, token);
if (host.id !== room.seats[0]?.id) throw new RoomError('Only the wizard who opened the table may make a phrase for another seat.', 403);
const seat = room.seats.find((s) => s.id === seatId && s.id !== host.id && (!s.bot || s.standIn));
if (!seat) throw new RoomError('No other wizard sits in that seat.', 404);
return this.mint(room.id, seat.id, null);
}
private mint(roomId: string, seatId: WizardId, token: string | null): { phrase: string; expiresAt: number } {
const now = Date.now();
for (const [phrase, t] of this.transfers) if (t.expiresAt < now) this.transfers.delete(phrase);
if (this.transfers.size >= 200) throw new RoomError('Too many transfers are pending; try again in a few minutes.', 429);
@@ -246,7 +267,7 @@ export class Rooms {
phrase = Array.from({ length: 4 }, () => TRANSFER_WORDS[randomBytes(1)[0] % TRANSFER_WORDS.length]).join('-');
} while (this.transfers.has(phrase));
const expiresAt = now + TRANSFER_TTL_MS;
this.transfers.set(phrase, { roomId: room.id, seatId: seat.id, token: token!, expiresAt });
this.transfers.set(phrase, { roomId, seatId, token, expiresAt });
return { phrase, expiresAt };
}
@@ -267,6 +288,10 @@ export class Rooms {
}
this.transfers.delete(phrase);
const room = this.get(t.roomId);
if (t.token === null) {
const { seat, token } = this.reseat(room, t.seatId);
return { roomId: room.id, seat: seat.id, token };
}
const seat = this.seatOf(room, t.token);
return { roomId: room.id, seat: seat.id, token: t.token };
}
@@ -308,27 +333,49 @@ export class Rooms {
}
/**
* The keeper gives a wizard'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.
* A wizard's seat gets a new token, for a player whose browser lost the old one, or one taking it back from a
* bot standing in. Whether the one asking holds the seat is the keeper's judgment, or the host's in the phrase
* they made; this carries it out. The old token opens nothing after.
*/
reseat(room: Room, seatId: unknown): { seat: Seat; token: string } {
const seat = room.seats.find((s) => s.id === seatId && !s.bot);
const seat = room.seats.find((s) => s.id === seatId && (!s.bot || s.standIn));
if (!seat) throw new RoomError('No wizard sits in that seat.', 404);
const token = newId(18);
this.commit(room, { t: 'reseat', at: Date.now(), id: seat.id, tokenHash: hashToken(token) });
// A phrase minted with the old token would carry a seat that no longer answers to it.
for (const [phrase, t] of this.transfers) if (t.roomId === room.id && t.seatId === seat.id) this.transfers.delete(phrase);
this.voidPhrases(room, seat.id);
return { seat, token };
}
/**
* The host gives a wizard's seat to a bot, for a player who cannot go on: the bot plays it from the gestures the
* seat owes, and the wizard's token opens nothing until a phrase from the host gives it back.
*/
standIn(room: Room, token: string | undefined, seatId: unknown): void {
const host = this.seatOf(room, token);
if (host.id !== room.seats[0]?.id) throw new RoomError('Only the wizard who opened the table may give a seat to a bot.', 403);
if (!room.state || room.state.over) throw new RoomError('Only a duel under way needs a stand-in.', 409);
if (seatId === host.id) throw new RoomError('The host keeps their own seat.', 409);
const seat = room.seats.find((s) => s.id === seatId && !s.bot);
if (!seat) throw new RoomError('No wizard sits in that seat.', 404);
this.commit(room, { t: 'standIn', at: Date.now(), id: seat.id, by: host.id });
this.voidPhrases(room, seat.id);
// The turn may have been waiting on this seat alone.
if (waitingOn(room).length === 0) this.resolve(room);
}
/** A phrase minted for a seat whose token has changed hands would carry a seat that no longer answers to it. */
private voidPhrases(room: Room, seatId: WizardId): void {
for (const [phrase, t] of this.transfers) if (t.roomId === room.id && t.seatId === seatId) this.transfers.delete(phrase);
}
/** The table as the keeper sees it before re-seating: who sits where, and when each wizard last moved or spoke. */
seatsForKeeper(room: Room): { id: WizardId; name: string; bot: boolean; lastActed: number | null }[] {
seatsForKeeper(room: Room): { id: WizardId; name: string; bot: boolean; standIn: boolean; lastActed: number | null }[] {
const last = new Map<WizardId, number>();
for (const line of this.store.read(room.id)) {
if (line.t === 'input' || line.t === 'chat') last.set(line.id, line.at);
else if (line.t === 'turn') for (const id of Object.keys(line.inputs)) last.set(id, line.at);
}
return room.seats.map((s) => ({ id: s.id, name: s.name, bot: s.bot, lastActed: s.bot ? null : (last.get(s.id) ?? null) }));
return room.seats.map((s) => ({ id: s.id, name: s.name, bot: s.bot && !s.standIn, standIn: !!s.standIn, lastActed: s.bot && !s.standIn ? null : (last.get(s.id) ?? null) }));
}
/** The host sends a bot away before the duel begins. People leave by not coming back. */
@@ -361,8 +408,9 @@ export class Rooms {
private resolve(room: Room): void {
const state = room.state!;
const inputs: Record<WizardId, TurnInput> = { ...room.pending };
// Gestures a wizard made before a bot stood in for them stand.
for (const seat of room.seats) {
if (seat.bot && needsInput(state, seat.id)) inputs[seat.id] = chooseBotTurn(state, seat.id);
if (seat.bot && needsInput(state, seat.id) && !(seat.id in inputs)) inputs[seat.id] = chooseBotTurn(state, seat.id);
}
this.commit(room, { t: 'turn', at: Date.now(), inputs });
// A time stop owed to a bot resolves at once; a human's waits for them.
@@ -387,7 +435,7 @@ export class Rooms {
size: room.size,
me: seatId,
host: room.seats[0]?.id ?? seatId,
seats: room.seats.map((s) => ({ id: s.id, name: s.name, bot: s.bot })),
seats: room.seats.map((s) => ({ id: s.id, name: s.name, bot: s.bot, ...(s.standIn ? { standIn: true as const } : {}) })),
waitingOn: waitingOn(room),
resolved: room.resolved,
state: room.state ? viewFor(room.state, seatId) : null,
@@ -463,7 +511,17 @@ function apply(room: Room, line: LedgerLine): void {
break;
case 'reseat': {
const seat = room.seats.find((s) => s.id === line.id);
if (seat) seat.tokenHash = line.tokenHash;
if (!seat) break;
seat.tokenHash = line.tokenHash;
seat.bot = false;
delete seat.standIn;
break;
}
case 'standIn': {
const seat = room.seats.find((s) => s.id === line.id);
if (!seat) break;
seat.bot = true;
seat.standIn = true;
break;
}
case 'input':
+6 -1
View File
@@ -11,8 +11,13 @@ export type LedgerLine =
| { t: 'room'; id: string; seats: number; createdAt: number; rematchOf?: string; expected?: string[] }
/** A seat holds only the hash of its token; the token itself goes once to the browser that earned it. Ledgers written before hashing carry `token` and are read once more. */
| { t: 'seat'; id: WizardId; name: string; tokenHash?: string; token?: string; bot: boolean }
/** The keeper gave a wizard's seat a new token, for a player whose browser lost the old one; the old one opens nothing after. */
/**
* A wizard's seat got a new token, from the keeper or a phrase the host made, for a player whose browser lost the
* old one; the old one opens nothing after. A seat a bot was standing in for is the wizard's again.
*/
| { t: 'reseat'; at: number; id: WizardId; tokenHash: string }
/** The host gave a wizard's seat to a bot, which duels in it from here on; a reseat gives it back. */
| { t: 'standIn'; at: number; id: WizardId; by: WizardId }
/** One seat's move for the turn in progress, so a restart keeps it. The turn line that follows carries every input again. */
| { t: 'input'; at: number; id: WizardId; input: TurnInput }
/** A bot sent away by the host before the duel began; its seat id is free again. */