Seats are banked in a cookie the server sets, and a lost seat comes back by name to the address it was last used from
A seat lived in the browser's storage alone, and Safari clears that after a week away, or at a word from the person on another device. Now every seat taken is banked as well in an HttpOnly cookie the server writes, which such clearing leaves alone, and the hall takes its seats back from the bank on arrival. For a browser that lost even the cookie, the ledger notes the address each seat was last used from, as a salted hash, at most once in ten minutes, and a name given in the hall or at the duel gets its seats back from the same address, with a new token; where the address differs, two people at the duel share it, or the host gave the seat to a bot, the duel is only counted, and the host's phrase or the keeper's link is the way back. The keeper's script finds a name across duels. The seat endpoints answer only this site's own pages, under a limit of their own. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CCLa2PaEeCj8njQZSWGqHx
This commit is contained in:
1 parent
b8f65a74d3
commit
29a68f4d9d
8 files changed
+443
-13
No files matched your search
+181
-6
@@ -17,6 +17,11 @@
|
||||
// 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
|
||||
// POST /api/seats {seats: [{roomId, token}], forget?: [roomId]} bank this browser's seats in a cookie the server
|
||||
// sets, and get back every seat the cookie holds; a browser whose storage was
|
||||
// cleared gets its seats from the cookie, which clearing storage leaves alone
|
||||
// POST /api/seats/find {name, roomId?} the seats a name holds at duels not yet over: those last used from this
|
||||
// address are re-seated and returned; the rest only counted
|
||||
// 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
|
||||
// POST /api/rooms/:id/report {token?, happened, expected} a report to the keeper, pinned to the turn
|
||||
@@ -29,21 +34,22 @@
|
||||
// door answers only on the host itself, and only to the key in KEEPER_KEY_FILE
|
||||
// (deploy/waving-hands-reseat.sh sends it):
|
||||
//
|
||||
// GET /keeper/seats?name= every table not yet over where a person of that name sits, for a player who lost the codes too
|
||||
// GET /keeper/rooms/:id who sits at the table, and when each wizard last moved or spoke
|
||||
// POST /keeper/rooms/:id/reseat {seat} a new token for a player locked out, as a link to send them
|
||||
// POST /keeper/rooms/:id/say {text} a word from the keeper in the table's talk
|
||||
|
||||
import * as Sentry from '@sentry/node';
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
import { timingSafeEqual } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
import { RateLimit } from './ratelimit';
|
||||
import { IMAGE_MAX_BYTES, Reports } from './reports';
|
||||
import { RoomError, Rooms, SPECTATOR } from './rooms';
|
||||
import { Store } from './store';
|
||||
import { Tallies } from './tally';
|
||||
import { dirname } from 'node:path';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const PORT = Number(process.env.PORT ?? 8788);
|
||||
const HOST = process.env.HOST ?? '127.0.0.1';
|
||||
@@ -54,6 +60,12 @@ const KEEPER_TZ = process.env.KEEPER_TZ ?? 'America/New_York';
|
||||
/** The keeper's key, readable by root and this game's user alone, so another service on the host cannot use the keeper's door. Absent, the door stays shut. */
|
||||
const KEEPER_KEY_FILE = process.env.KEEPER_KEY_FILE ?? '/etc/waving-hands/keeper.key';
|
||||
const BODY_LIMIT = 16 * 1024;
|
||||
/** The seats a browser holds, banked in a cookie: the browser keeps it for the server alone, and sends it to the API only. */
|
||||
const SEATS_COOKIE = 'seats';
|
||||
/** As long as a browser will keep a cookie. */
|
||||
const SEATS_COOKIE_MAX_AGE = 400 * 24 * 60 * 60;
|
||||
/** The newest seats a cookie carries; the rest fall off the end. */
|
||||
const SEATS_COOKIE_KEEP = 60;
|
||||
|
||||
// Errors go to Sentry when a DSN is set; the SDK drops them otherwise.
|
||||
if (process.env.SENTRY_DSN) {
|
||||
@@ -81,6 +93,8 @@ const galleryVoices = new RateLimit(30, 10 * 60 * 1000);
|
||||
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);
|
||||
/** Searches for lost seats: each reads every duel written to lately, and a person loses their seats rarely. */
|
||||
const searches = new RateLimit(5, 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);
|
||||
@@ -91,6 +105,7 @@ setInterval(() => {
|
||||
voices.prune();
|
||||
desk.prune();
|
||||
galleryVoices.prune();
|
||||
searches.prune();
|
||||
bells.prune();
|
||||
claims.prune();
|
||||
if (n) console.log(`evicted ${n} idle room${n === 1 ? '' : 's'} from memory; ${rooms.loaded} loaded`);
|
||||
@@ -137,6 +152,146 @@ function ringBell(title: string, detail: { fingerprint: string[]; tags: Record<s
|
||||
Sentry.captureMessage(title, { level: 'error', ...detail });
|
||||
}
|
||||
|
||||
/** Addresses go on the ledgers as salted hashes. The salt is made once, beside the ledgers, and never leaves the host. */
|
||||
const SALT_FILE = join(dirname(DATA_DIR), 'addr.salt');
|
||||
function addrSalt(): string {
|
||||
try {
|
||||
const salt = readFileSync(SALT_FILE, 'utf8').trim();
|
||||
if (salt) return salt;
|
||||
} catch {
|
||||
// No salt yet: this is the first start.
|
||||
}
|
||||
const salt = randomBytes(32).toString('base64url');
|
||||
writeFileSync(SALT_FILE, salt + '\n', { mode: 0o600 });
|
||||
return salt;
|
||||
}
|
||||
const salt = addrSalt();
|
||||
/** The visitor's address as the ledgers note it: a hash a reader of the ledger cannot turn back into the address. */
|
||||
function addrOf(req: IncomingMessage): string {
|
||||
return createHmac('sha256', salt).update(clientOf(req)).digest('hex').slice(0, 16);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the request was sent by this site's own pages. A page elsewhere could have a visitor's browser post here
|
||||
* on its behalf; the browser names that page's origin, and the request is refused. The cookie is not sent across
|
||||
* sites in any case, so what such a page could do is re-key a visitor's own seats under a name, to no end but nuisance.
|
||||
*/
|
||||
function fromThisSite(req: IncomingMessage): boolean {
|
||||
const origin = req.headers.origin;
|
||||
if (!origin) return true;
|
||||
try {
|
||||
return new URL(origin).host === req.headers.host;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** The seats the browser's cookie banks, as room and token; a cookie this server never wrote reads as none. */
|
||||
function bankedSeats(req: IncomingMessage): { roomId: string; token: string }[] {
|
||||
const header = req.headers.cookie ?? '';
|
||||
const pair = header.split(';').map((c) => c.trim()).find((c) => c.startsWith(`${SEATS_COOKIE}=`));
|
||||
if (!pair) return [];
|
||||
return pair
|
||||
.slice(SEATS_COOKIE.length + 1)
|
||||
.split(':')
|
||||
.map((entry) => entry.split('.'))
|
||||
.filter((e): e is [string, string] => e.length === 2 && /^[A-Z0-9]{4}$/.test(e[0]) && /^[A-Za-z0-9_-]{1,64}$/.test(e[1]))
|
||||
.map(([roomId, token]) => ({ roomId, token }));
|
||||
}
|
||||
|
||||
/** The cookie written for the seats given, newest last; sent only to the API, only over https where there is any, never to a script. */
|
||||
function bankSeats(req: IncomingMessage, res: ServerResponse, seats: { roomId: string; token: string }[]): void {
|
||||
const value = seats.slice(-SEATS_COOKIE_KEEP).map((s) => `${s.roomId}.${s.token}`).join(':');
|
||||
const secure = req.headers['x-forwarded-proto'] === 'https' ? '; Secure' : '';
|
||||
res.setHeader('set-cookie', `${SEATS_COOKIE}=${value}; Path=/api; Max-Age=${SEATS_COOKIE_MAX_AGE}; HttpOnly; SameSite=Lax${secure}`);
|
||||
}
|
||||
|
||||
/** The seats a browser claims, from its cookie and its own storage, kept to the ones their tokens still open, each room once. */
|
||||
function provenBank(req: IncomingMessage, claimed: unknown, forget: unknown): { roomId: string; seat: string; token: string }[] {
|
||||
const dropped = new Set(Array.isArray(forget) ? forget.slice(0, 50).map((r) => String(r).toUpperCase()) : []);
|
||||
const own = Array.isArray(claimed) ? claimed.slice(0, SEATS_COOKIE_KEEP) : [];
|
||||
const byRoom = new Map<string, string>();
|
||||
// The browser's own copy is the fresher where the two differ, so it is taken last.
|
||||
for (const c of [...bankedSeats(req), ...own]) {
|
||||
if (typeof c !== 'object' || c === null) continue;
|
||||
const { roomId, token } = c as Record<string, unknown>;
|
||||
if (typeof roomId !== 'string' || typeof token !== 'string' || !token) continue;
|
||||
const id = roomId.toUpperCase();
|
||||
if (!dropped.has(id)) byRoom.set(id, token);
|
||||
}
|
||||
const proven: { roomId: string; seat: string; token: string }[] = [];
|
||||
for (const [roomId, token] of byRoom) {
|
||||
try {
|
||||
const room = rooms.get(roomId);
|
||||
proven.push({ roomId: room.id, seat: rooms.seatOf(room, token).id, token });
|
||||
} catch {
|
||||
// A seat whose token no longer opens is not banked again.
|
||||
}
|
||||
}
|
||||
return proven;
|
||||
}
|
||||
|
||||
function send(res: ServerResponse, status: number, body: unknown): void {
|
||||
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
||||
res.end(status === 204 ? undefined : JSON.stringify(body));
|
||||
}
|
||||
|
||||
function readBytes(req: IncomingMessage, limit: number, tooBig: string): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const declared = Number(req.headers['content-length'] ?? 0);
|
||||
if (declared > limit) {
|
||||
reject(new RoomError(tooBig, 413));
|
||||
req.destroy();
|
||||
return;
|
||||
}
|
||||
let size = 0;
|
||||
const chunks: Buffer[] = [];
|
||||
req.on('data', (chunk: Buffer) => {
|
||||
size += chunk.length;
|
||||
if (size > limit) {
|
||||
reject(new RoomError(tooBig, 413));
|
||||
req.destroy();
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
req.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function readBody(req: IncomingMessage): Promise<Record<string, unknown>> {
|
||||
const bytes = await readBytes(req, BODY_LIMIT, 'That is more than a move needs.');
|
||||
if (bytes.length === 0) return {};
|
||||
try {
|
||||
return JSON.parse(bytes.toString('utf8')) as Record<string, unknown>;
|
||||
} catch {
|
||||
throw new RoomError('The request was not JSON.');
|
||||
}
|
||||
}
|
||||
|
||||
/** A report, an answer, or a call to the table is worth waking the keeper for; the desk's own replies are not. */
|
||||
function ringBell(title: string, detail: { fingerprint: string[]; tags: Record<string, string>; extra: Record<string, unknown> }): void {
|
||||
Sentry.captureMessage(title, { level: 'error', ...detail });
|
||||
}
|
||||
|
||||
/** The seats a browser claims, kept to the ones its tokens prove. */
|
||||
function provenSeats(raw: unknown): { roomId: string; seat: string }[] {
|
||||
const claims = Array.isArray(raw) ? raw.slice(0, 50) : [];
|
||||
const proven: { roomId: string; seat: string }[] = [];
|
||||
for (const claim of claims) {
|
||||
if (typeof claim !== 'object' || claim === null) continue;
|
||||
const c = claim as Record<string, unknown>;
|
||||
try {
|
||||
const room = rooms.get(String(c.roomId ?? ''));
|
||||
proven.push({ roomId: room.id, seat: rooms.seatOf(room, typeof c.token === 'string' ? c.token : undefined).id });
|
||||
} catch {
|
||||
// A seat this browser cannot prove is not its business.
|
||||
}
|
||||
}
|
||||
return proven;
|
||||
}
|
||||
|
||||
/** The seats a browser claims, kept to the ones its tokens prove. */
|
||||
function provenSeats(raw: unknown): { roomId: string; seat: string }[] {
|
||||
const claims = Array.isArray(raw) ? raw.slice(0, 50) : [];
|
||||
@@ -225,8 +380,10 @@ function keeperKeyGiven(req: IncomingMessage): boolean {
|
||||
* The keeper's door. Caddy proxies only /api and /ws, so it is reached from the host alone; a request Caddy
|
||||
* forwarded is turned away besides, and so is one without the keeper's key.
|
||||
*/
|
||||
async function handleKeeper(req: IncomingMessage, res: ServerResponse, parts: string[]): Promise<void> {
|
||||
if (req.headers['x-forwarded-for'] || parts[1] !== 'rooms' || !keeperKeyGiven(req)) throw new RoomError('Not here.', 404);
|
||||
async function handleKeeper(req: IncomingMessage, res: ServerResponse, parts: string[], url: URL): Promise<void> {
|
||||
if (req.headers['x-forwarded-for'] || !keeperKeyGiven(req)) throw new RoomError('Not here.', 404);
|
||||
if (parts[1] === 'seats' && parts.length === 2 && req.method === 'GET') return send(res, 200, { tables: rooms.tablesForKeeper(url.searchParams.get('name') ?? '') });
|
||||
if (parts[1] !== 'rooms') throw new RoomError('Not here.', 404);
|
||||
const room = rooms.get(parts[2] ?? '');
|
||||
if (parts.length === 3 && req.method === 'GET') return send(res, 200, { roomId: room.id, started: !!room.state, seats: rooms.seatsForKeeper(room) });
|
||||
if (parts[3] === 'reseat' && parts.length === 4 && req.method === 'POST') {
|
||||
@@ -246,7 +403,7 @@ async function handleKeeper(req: IncomingMessage, res: ServerResponse, parts: st
|
||||
async function handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||
const url = new URL(req.url ?? '/', 'http://localhost');
|
||||
const parts = url.pathname.split('/').filter(Boolean);
|
||||
if (parts[0] === 'keeper') return handleKeeper(req, res, parts);
|
||||
if (parts[0] === 'keeper') return handleKeeper(req, res, parts, url);
|
||||
if (parts[0] === 'api' && parts[1] === 'reports') return handleReports(req, res, parts);
|
||||
if (parts[0] === 'api' && parts[1] === 'tally' && parts.length === 2 && req.method === 'GET') return send(res, 200, tallies.get());
|
||||
if (parts[0] === 'api' && parts[1] === 'transfer' && parts.length === 2 && req.method === 'POST') {
|
||||
@@ -254,6 +411,22 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
|
||||
const body = await readBody(req);
|
||||
return send(res, 200, rooms.claim(body.phrase));
|
||||
}
|
||||
if (parts[0] === 'api' && parts[1] === 'seats' && req.method === 'POST') {
|
||||
if (!fromThisSite(req) || !String(req.headers['content-type'] ?? '').startsWith('application/json')) throw new RoomError('Not from here.', 403);
|
||||
const body = await readBody(req);
|
||||
if (parts.length === 2) {
|
||||
const seats = provenBank(req, body.seats, body.forget);
|
||||
bankSeats(req, res, seats);
|
||||
return send(res, 200, { seats });
|
||||
}
|
||||
if (parts[2] === 'find' && parts.length === 3) {
|
||||
if (!searches.allow(clientOf(req))) throw new RoomError('Too many searches from here just now; try again later.', 429);
|
||||
const found = rooms.findSeats(String(body.name ?? ''), addrOf(req), typeof body.roomId === 'string' ? body.roomId : undefined);
|
||||
// The seats given back are banked at once, so the cookie and the browser agree.
|
||||
if (found.seated.length) bankSeats(req, res, provenBank(req, found.seated, []));
|
||||
return send(res, 200, found);
|
||||
}
|
||||
throw new RoomError('Not here.', 404);
|
||||
if (parts[0] !== 'api' || parts[1] !== 'rooms') throw new RoomError('Not here.', 404);
|
||||
|
||||
if (parts.length === 2 && req.method === 'POST') {
|
||||
@@ -268,10 +441,12 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
|
||||
if (!action && req.method === 'GET') {
|
||||
const token = url.searchParams.get('token');
|
||||
if (!token) return send(res, 200, rooms.view(room, SPECTATOR));
|
||||
rooms.seen(room, token, addrOf(req));
|
||||
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
|
||||
}
|
||||
if (req.method !== 'POST') throw new RoomError('Not here.', 404);
|
||||
const body = await readBody(req);
|
||||
if (typeof body.token === 'string' && body.token) rooms.seen(room, body.token, addrOf(req));
|
||||
if (action === 'join') {
|
||||
if (!doors.allow(clientOf(req))) throw new RoomError('Too many seats taken from here just now; try again later.', 429);
|
||||
const { seat, token: minted } = rooms.join(room, String(body.name ?? ''));
|
||||
|
||||
@@ -121,3 +121,54 @@ describe('the host and the other seats', () => {
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('a seat a browser lost', () => {
|
||||
it('notes the address a seat is used from, once per change and not on its heels, without waking the duel, and a restart keeps it', () => {
|
||||
const { rooms, room, bo } = table();
|
||||
const seq = room.seq;
|
||||
rooms.seen(room, bo, 'home');
|
||||
rooms.seen(room, bo, 'home');
|
||||
expect(room.seats.find((s) => s.id === 'B')?.addr).toBe('home');
|
||||
expect(room.seq).toBe(seq);
|
||||
rooms.seen(room, 'no-such-token', 'elsewhere');
|
||||
rooms.seen(room, bo, 'office');
|
||||
expect(new Store(dir).read(room.id).filter((l) => l.t === 'seen')).toHaveLength(1);
|
||||
expect(new Rooms(new Store(dir)).get(room.id).seats.find((s) => s.id === 'B')?.addr).toBe('home');
|
||||
});
|
||||
|
||||
it('gives the seat back, under its name, to the address it was last used from, with a new token', () => {
|
||||
const { rooms, room, bo } = table();
|
||||
rooms.seen(room, bo, 'home');
|
||||
const found = rooms.findSeats('bo', 'home');
|
||||
expect(found.elsewhere).toBe(0);
|
||||
expect(found.seated.map((s) => [s.roomId, s.seat])).toEqual([[room.id, 'B']]);
|
||||
expect(rooms.seatOf(room, found.seated[0].token).name).toBe('Bo');
|
||||
expect(() => rooms.seatOf(room, bo)).toThrow(/opens no seat/);
|
||||
});
|
||||
|
||||
it('only counts the duel when the address differs, or another person at it shares the address, or a bot stands in', async () => {
|
||||
const { rooms, room, ann, bo } = table();
|
||||
rooms.seen(room, bo, 'home');
|
||||
expect(rooms.findSeats('Bo', 'away')).toEqual({ seated: [], elsewhere: 1 });
|
||||
rooms.seen(room, ann, 'home');
|
||||
expect(rooms.findSeats('Bo', 'home')).toEqual({ seated: [], elsewhere: 1 });
|
||||
expect(rooms.findSeats('Dee', 'home')).toEqual({ seated: [], elsewhere: 0 });
|
||||
expect(rooms.seatOf(room, bo).id).toBe('B');
|
||||
rooms.standIn(room, ann, 'B');
|
||||
await new Promise<void>((r) => setImmediate(r));
|
||||
expect(rooms.findSeats('Bo', 'home')).toEqual({ seated: [], elsewhere: 1 });
|
||||
expect(rooms.tablesForKeeper('bo')).toMatchObject([{ roomId: room.id, seat: 'B', standIn: true, players: ['Ann'] }]);
|
||||
expect(rooms.tablesForKeeper('Dee')).toEqual([]);
|
||||
});
|
||||
|
||||
it('looks only at the duel named when one is, and passes over a duel that is over', () => {
|
||||
const { rooms, room, bo } = table();
|
||||
rooms.seen(room, bo, 'home');
|
||||
const other = rooms.create('Bo', 3);
|
||||
rooms.seen(other.room, other.token, 'home');
|
||||
expect(rooms.findSeats('Bo', 'home', other.room.id).seated.map((s) => s.roomId)).toEqual([other.room.id]);
|
||||
expect(rooms.findSeats('Bo', 'home').seated.map((s) => s.roomId).sort()).toEqual([other.room.id, room.id].sort());
|
||||
room.over = true;
|
||||
expect(rooms.findSeats('Bo', 'home').seated.map((s) => s.roomId)).toEqual([other.room.id]);
|
||||
});
|
||||
});
|
||||
@@ -29,8 +29,15 @@ export interface Seat {
|
||||
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;
|
||||
/** The address this person's seat was last used from, as a salted hash; a seat their browser lost goes back to the same address. */
|
||||
addr?: string;
|
||||
}
|
||||
|
||||
/** A table whose ledger has not been written in this long is not looked through for a lost seat. */
|
||||
const LOST_SEAT_LOOKBACK_MS = 90 * 24 * 60 * 60 * 1000;
|
||||
/** A seat's address is noted at most this often: one seat open on two networks at once must not write a line per request. */
|
||||
const SEEN_EVERY_MS = 10 * 60 * 1000;
|
||||
|
||||
export function hashToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
@@ -276,6 +283,73 @@ export class Rooms {
|
||||
return { phrase, expiresAt };
|
||||
}
|
||||
|
||||
/** When each seat's address was last noted, by room and seat. */
|
||||
private seenAt = new Map<string, number>();
|
||||
|
||||
/** The address a seat is used from, noted when it changes, and not more often than SEEN_EVERY_MS. Not a change the table need hear of: nothing wakes for it. */
|
||||
seen(room: Room, token: string | undefined, addr: string): void {
|
||||
let seat: Seat;
|
||||
try {
|
||||
seat = this.seatOf(room, token);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (seat.addr === addr) return;
|
||||
// Keyed by the token too: a seat given a new token is noted afresh at once, whoever held it before.
|
||||
const key = `${room.id}/${seat.id}/${seat.tokenHash}`;
|
||||
const now = Date.now();
|
||||
if ((this.seenAt.get(key) ?? 0) > now - SEEN_EVERY_MS) return;
|
||||
this.seenAt.set(key, now);
|
||||
const line: LedgerLine = { t: 'seen', at: now, id: seat.id, addr };
|
||||
this.store.append(room.id, line);
|
||||
apply(room, line);
|
||||
}
|
||||
|
||||
/** The tables not yet over, written to lately, where a person of this name sits; one table alone when it is named. */
|
||||
private tablesOf(name: string, only?: string): { room: Room; seat: Seat }[] {
|
||||
const wanted = cleanName(name).toLowerCase();
|
||||
const since = Date.now() - LOST_SEAT_LOOKBACK_MS;
|
||||
const ids = only ? [normalizeCode(String(only))] : this.store.roomIds().filter((id) => this.store.updatedAt(id) > since);
|
||||
const found: { room: Room; seat: Seat }[] = [];
|
||||
for (const id of ids) {
|
||||
let room: Room;
|
||||
try {
|
||||
room = this.get(id);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (room.over) continue;
|
||||
const seat = room.seats.find((s) => (!s.bot || s.standIn) && s.name.toLowerCase() === wanted);
|
||||
if (seat) found.push({ room, seat });
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* The seats a name holds at tables not yet over, for a browser that lost them. A seat last used from the
|
||||
* asker's own address, at a table where no other person shares that address, is theirs again with a new
|
||||
* token, the old one dead. The rest are only counted: a name proves nothing, so neither the tables nor the
|
||||
* company at them are told to the asker, who can get a phrase from the host, or a link from the keeper. A
|
||||
* seat the host gave to a bot is the host's to give back, by a phrase, never taken back by name.
|
||||
*/
|
||||
findSeats(rawName: string, addr: string, only?: string): { seated: { roomId: string; seat: WizardId; token: string }[]; elsewhere: number } {
|
||||
const seated: { roomId: string; seat: WizardId; token: string }[] = [];
|
||||
let elsewhere = 0;
|
||||
for (const { room, seat } of this.tablesOf(rawName, only)) {
|
||||
const shared = room.seats.some((s) => s !== seat && (!s.bot || s.standIn) && s.addr === addr);
|
||||
if (seat.addr === addr && !shared && !seat.standIn) {
|
||||
const { token } = this.reseat(room, seat.id);
|
||||
seated.push({ roomId: room.id, seat: seat.id, token });
|
||||
} else elsewhere += 1;
|
||||
}
|
||||
return { seated, elsewhere };
|
||||
}
|
||||
|
||||
/** For the keeper: every table not yet over where a person of this name sits, with the company there, to re-seat them by. */
|
||||
tablesForKeeper(name: string): { roomId: string; seat: WizardId; standIn: boolean; players: string[]; updatedAt: number }[] {
|
||||
return this.tablesOf(name).map(({ room, seat }) => ({ roomId: room.id, seat: seat.id, standIn: !!seat.standIn, players: room.seats.filter((s) => s !== seat).map((s) => s.name), updatedAt: room.updatedAt }));
|
||||
}
|
||||
|
||||
/** Claim a phrase: the seat and its token, once. Too many misses void every pending phrase. */
|
||||
claim(raw: unknown): { roomId: string; seat: WizardId; token: string } {
|
||||
const now = Date.now();
|
||||
@@ -514,6 +588,11 @@ function apply(room: Room, line: LedgerLine): void {
|
||||
case 'seat':
|
||||
room.seats.push({ id: line.id, name: line.name, tokenHash: line.tokenHash ?? (line.token ? hashToken(line.token) : ''), bot: line.bot });
|
||||
break;
|
||||
case 'seen': {
|
||||
const seat = room.seats.find((s) => s.id === line.id);
|
||||
if (seat) seat.addr = line.addr;
|
||||
break;
|
||||
}
|
||||
case 'reseat': {
|
||||
const seat = room.seats.find((s) => s.id === line.id);
|
||||
if (!seat) break;
|
||||
|
||||
+14
-2
@@ -2,7 +2,7 @@
|
||||
// top: the engine is deterministic given the seed and the recorded inputs, so
|
||||
// nothing else needs saving.
|
||||
|
||||
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from 'node:fs';
|
||||
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { TurnInput, WizardId } from '../../src/lib/game/state';
|
||||
|
||||
@@ -34,7 +34,9 @@ export type LedgerLine =
|
||||
/** A seat is held for the keeper of the site, called to the table. */
|
||||
| { t: 'challenge'; at: number; by: WizardId; keeper: string }
|
||||
/** A finished table called for a rematch: where it went, and who called. */
|
||||
| { t: 'rematch'; at: number; to: string; by: WizardId };
|
||||
| { t: 'rematch'; at: number; to: string; by: WizardId }
|
||||
/** The address a person's seat was last used from, as a salted hash, so a seat a browser lost can go back to the same address. */
|
||||
| { t: 'seen'; at: number; id: WizardId; addr: string };
|
||||
|
||||
export class Store {
|
||||
constructor(private dir: string) {
|
||||
@@ -63,6 +65,16 @@ export class Store {
|
||||
return existsSync(this.path(roomId));
|
||||
}
|
||||
|
||||
/** When the ledger was last written, for passing over tables long finished. */
|
||||
updatedAt(roomId: string): number {
|
||||
try {
|
||||
return statSync(this.path(roomId)).mtimeMs;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
roomIds(): string[] {
|
||||
return readdirSync(this.dir)
|
||||
.filter((f) => f.endsWith('.jsonl'))
|
||||
|
||||
Reference in new issue
Block a user