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:
1 parent
a673ea9766
commit
f98bfba107
14 files changed
+438
-31
No files matched your search
@@ -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}`);
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user