diff --git a/deploy/waving-hands-reseat.sh b/deploy/waving-hands-reseat.sh index e2e1ebd..1b70f29 100755 --- a/deploy/waving-hands-reseat.sh +++ b/deploy/waving-hands-reseat.sh @@ -3,16 +3,27 @@ # token, the old one stops working, and the link printed carries the new one. # Whether the one asking holds that seat is the keeper's call; send the link # only to them. Runs ON the droplet, through the duel server's keeper door. +# waving-hands-reseat.sh find NAME every table not yet over where a person of that name sits, for one who lost the codes too # waving-hands-reseat.sh ROOM who sits at the table, and when each wizard last moved or spoke # waving-hands-reseat.sh ROOM SEAT re-seat SEAT and print the link set -euo pipefail -ROOM="${1:?usage: waving-hands-reseat.sh ROOM [SEAT]}" +ROOM="${1:?usage: waving-hands-reseat.sh find NAME | ROOM [SEAT]}" DOOR="http://127.0.0.1:8788/keeper/rooms/${ROOM^^}" [ -s /etc/waving-hands/keeper.key ] || { echo "No keeper's key at /etc/waving-hands/keeper.key; a deploy makes it." >&2; exit 1; } # The key goes as a header read from a file descriptor, never on a command line another user could see. KEY="@/dev/fd/3" exec 3< <(printf 'x-keeper-key: %s\n' "$(cat /etc/waving-hands/keeper.key)") -if [ $# -lt 2 ]; then +if [ "$ROOM" = find ]; then + curl -s -H "$KEY" --get --data-urlencode "name=${2:?a name to look for}" "http://127.0.0.1:8788/keeper/seats" | python3 -c ' +import datetime, json, sys +r = json.load(sys.stdin) +if "error" in r: sys.exit(r["error"]) +if not r["tables"]: print("no table still in play has a seat under that name") +for t in r["tables"]: + when = datetime.datetime.fromtimestamp(t["updatedAt"] / 1000, datetime.timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + print(" %s seat %-3s %s with %s, last moved %s" % (t["roomId"], t["seat"], "(a bot stands in)" if t["standIn"] else "", ", ".join(t["players"]) or "nobody yet", when)) +' +elif [ $# -lt 2 ]; then curl -s -H "$KEY" "$DOOR" | python3 -c ' import datetime, json, sys r = json.load(sys.stdin) diff --git a/server/src/index.ts b/server/src/index.ts index bb1e427..a3798fc 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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 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(); + // 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; + 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 { + 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> { + 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; + } 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; extra: Record }): 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; + 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 { - 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 { + 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 { 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 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 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 ?? '')); diff --git a/server/src/rooms.test.ts b/server/src/rooms.test.ts index 31a620d..f5eda56 100644 --- a/server/src/rooms.test.ts +++ b/server/src/rooms.test.ts @@ -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((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]); + }); +}); diff --git a/server/src/rooms.ts b/server/src/rooms.ts index 196317e..7c2d6ef 100644 --- a/server/src/rooms.ts +++ b/server/src/rooms.ts @@ -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(); + + /** 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; diff --git a/server/src/store.ts b/server/src/store.ts index 5fa01e4..94a7159 100644 --- a/server/src/store.ts +++ b/server/src/store.ts @@ -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')) diff --git a/src/lib/components/Hall.svelte b/src/lib/components/Hall.svelte index 33901b9..0940c3f 100644 --- a/src/lib/components/Hall.svelte +++ b/src/lib/components/Hall.svelte @@ -3,7 +3,7 @@ import TallyBoard from './Tally.svelte'; import { goto } from '$app/navigation'; import { loadFamily, type FamilyGame } from '$lib/family'; - import { allSeats, api, doubtSeat, forgetSeat, rememberSeat, ServerError, trustSeat, type Report, type Tally } from '$lib/game/client'; + import { restoreSeats, allSeats, api, doubtSeat, forgetSeat, rememberSeat, ServerError, trustSeat, type Report, type Tally } from '$lib/game/client'; import { Duel, duel, NAME_MAX, playerName, rememberName } from '$lib/game/duel.svelte'; import { unreadTalk } from '$lib/game/talk'; import type { RoomView } from '$lib/game/view'; @@ -13,6 +13,14 @@ let busy = $state(false); let error = $state(''); let seats = $state(allSeats()); + // The bank is asked once on arrival: a browser whose storage was cleared has its seats again before it misses them. + $effect(() => { + void restoreSeats() + .then((found) => { + if (found) seats = allSeats(); + }) + .catch(() => {}); + }); let games = $state>({}); let aboutOpen = $state(false); $effect(() => { @@ -62,6 +70,30 @@ let answers = $state>({}); let answerError = $state(''); + let finding = $state(false); + /** What a search for lost seats turned up, in words. */ + let found = $state(''); + /** Seats under this name at duels still in play: those last used from here are taken back; the rest are named, to ask the host. */ + async function findSeats() { + finding = true; + error = ''; + found = ''; + try { + rememberName(name); + const r = await api.findSeats(name.trim()); + for (const s of r.seated) rememberSeat(s.roomId, { seat: s.seat, token: s.token }); + if (r.seated.length) seats = allSeats(); + const back = r.seated.length ? `Seated again at ${r.seated.map((s) => s.roomId).join(', ')}.` : ''; + const n = r.elsewhere; + const ask = n ? ` ${n === 1 ? 'One more table' : `${n} more duels`} in play ${n === 1 ? 'has' : 'have'} a seat under your name, last used from another address, or given to a bot by the host: ask the host there for a transfer phrase, or write to the keeper.` : ''; + found = back + ask || 'No duel still in play has a seat under that name.'; + } catch (e) { + error = e instanceof Error ? e.message : 'The search did not go through.'; + } finally { + finding = false; + } + } + // Fetch the reports for every held seat; re-runs when the held seats change. $effect(() => { const held = seats; @@ -249,6 +281,8 @@ +

Lost your duels? If this browser forgot them, .

+ {#if found}

{found}

{/if} {#if error}

{error}

{/if} diff --git a/src/lib/game/client.ts b/src/lib/game/client.ts index 6e1e5ab..1b683a0 100644 --- a/src/lib/game/client.ts +++ b/src/lib/game/client.ts @@ -42,8 +42,32 @@ export function forgetSeat(roomId: string): void { } catch { // Nothing to forget without storage. } + void bankSeats([roomId]).catch(() => {}); } +/** + * The seats this browser holds are banked with the server as well, in a cookie it sets and the browser keeps for + * it alone. A browser whose storage is cleared, or that Safari clears for it after a week away, still has the + * cookie, and gets its seats back from it. The answer is every seat the bank holds that still opens. + */ +export async function bankSeats(forget: string[] = []): Promise<{ roomId: string; seat: WizardId; token: string }[]> { + const seats = allSeats().map(({ roomId, token }) => ({ roomId, token })); + const { seats: banked } = await call<{ seats: { roomId: string; seat: WizardId; token: string }[] }>('POST', '/api/seats', { seats, forget }); + return banked; +} + +/** The seats the bank holds that this browser does not, taken into storage; true when there were any. */ +export async function restoreSeats(): Promise { + let found = false; + for (const s of await bankSeats()) { + if (seatFor(s.roomId)) continue; + store(s.roomId, { seat: s.seat, token: s.token }); + found = true; + } + return found; +} + + /** * A seat the server refused is doubted, not dropped: a restarting server, a * stale answer or the wrong backend all answer with ignorance, and ignorance @@ -92,7 +116,7 @@ export function trustSeat(roomId: string): void { } } -export function rememberSeat(roomId: string, held: HeldSeat): void { +function store(roomId: string, held: HeldSeat): void { try { localStorage.setItem(SEATS_KEY, JSON.stringify({ ...heldSeats(), [roomId]: held })); } catch { @@ -100,6 +124,12 @@ export function rememberSeat(roomId: string, held: HeldSeat): void { } } +/** A seat taken is kept here and banked with the server, each time, so the two agree. */ +export function rememberSeat(roomId: string, held: HeldSeat): void { + store(roomId, held); + void bankSeats().catch(() => {}); +} + export class ServerError extends Error { constructor( message: string, @@ -173,6 +203,8 @@ export const api = { hostPhrase: (roomId: string, token: string, seat: WizardId) => call<{ phrase: string; expiresAt: number }>('POST', `/api/rooms/${roomId}/phrase`, { token, seat }), standIn: (roomId: string, token: string, seat: WizardId) => call('POST', `/api/rooms/${roomId}/standin`, { token, seat }), claim: (phrase: string) => call<{ roomId: string; seat: WizardId; token: string }>('POST', '/api/transfer', { phrase }), + /** The seats a name holds at duels not over: those last used from this address come back seated; the rest are only counted. */ + findSeats: (name: string, roomId?: string) => call<{ seated: { roomId: string; seat: WizardId; token: string }[]; elsewhere: number }>('POST', '/api/seats/find', { name, ...(roomId ? { roomId } : {}) }), report: (roomId: string, token: string, happened: string, expected: string) => call<{ id: string }>('POST', `/api/rooms/${roomId}/report`, { token, happened, expected }), reportImage: async (id: string, file: File) => { diff --git a/src/routes/join/[code]/+page.svelte b/src/routes/join/[code]/+page.svelte index 84afbef..ae82bc3 100644 --- a/src/routes/join/[code]/+page.svelte +++ b/src/routes/join/[code]/+page.svelte @@ -177,12 +177,44 @@ room = new Duel(await Duel.joinRoom(roomId, name)); joining = false; } catch (err) { + // A seat already under this name may be this person's own, lost with the browser's storage: it is looked for first. + if (err instanceof ServerError && err.status === 409 && (await sitBackDown())) return; error = err instanceof Error ? err.message : 'The seat could not be taken.'; } finally { busy = false; } } + /** The seat under this name at this table, back again if it was last used from this address; true when it was. */ + async function sitBackDown(): Promise { + const r = await api.findSeats(name.trim(), roomId).catch(() => null); + const mine = r?.seated.find((s) => s.roomId === roomId); + if (!mine) { + if (r?.elsewhere) error = 'A seat here is under that name, but it was last used from another address, or the host gave it to a bot. Ask the host for a transfer phrase, or write to the keeper.'; + return false; + } + rememberSeat(roomId, { seat: mine.seat, token: mine.token }); + trustSeat(roomId); + seatLost = ''; + room = new Duel(await Duel.resumeRoom(roomId, { seat: mine.seat, token: mine.token })); + joining = false; + return true; + } + + /** From the gallery of a duel begun: the seat under this name, if it was last used from here. */ + async function findMySeat(e: SubmitEvent) { + e.preventDefault(); + busy = true; + error = ''; + try { + if (!(await sitBackDown()) && !error) error = 'No seat here is under that name.'; + } catch (err) { + error = err instanceof Error ? err.message : 'The seat could not be found.'; + } finally { + busy = false; + } + } + async function copyLink() { try { await navigator.clipboard.writeText(link); @@ -262,7 +294,11 @@
{#if seatLost}

{seatLost} Claim it here, or write to the keeper.

{:else}{#if room.remote.expected.length > 0}

{room.speaker(room.remote.host)} began the duel before {room.remote.expected.join(' and ')} arrived, and the seat held for {room.remote.expected.length === 1 ? 'them' : 'each'} went with it.

{/if} -

Had a seat at this duel? Ask the host for a transfer phrase, or make one on the device that still holds the seat, and claim it here. Or write to the keeper for a link that brings it back.

{/if} +

Had a seat at this duel? Give your name, and if the seat was last used from here it is yours again. Otherwise ask the host for a transfer phrase, or make one on the device that still holds the seat, and claim it here. Or write to the keeper for a link that brings it back.

{/if} +
+ + +