diff --git a/deploy/waving-hands.service b/deploy/waving-hands.service index 81efab7..0677c3d 100644 --- a/deploy/waving-hands.service +++ b/deploy/waving-hands.service @@ -7,6 +7,8 @@ Type=simple User=waving-hands WorkingDirectory=/opt/waving-hands/app/server Environment=PORT=8788 +Environment=KEEPER=Kestrel +Environment=KEEPER_TZ=America/New_York # Caddy terminates TLS; the plaintext port must not face the internet. Environment=HOST=127.0.0.1 Environment=WH_DATA_DIR=/var/lib/waving-hands/rooms diff --git a/server/src/index.ts b/server/src/index.ts index 8d5e479..610257f 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -8,7 +8,13 @@ // POST /api/rooms/:id/unseat {token, seat} the host sends a bot away before the duel begins // GET /api/rooms/:id?token= the view for that seat; without a token, the gallery's view // POST /api/rooms/:id/turn {token, input} this seat's move -// POST /api/rooms/:id/say {token, text} table talk, from a seat to the whole room +// POST /api/rooms/:id/say {token, text} table talk, from a seat to the whole room; +// {name, text} from the gallery, when the host has let it talk +// POST /api/rooms/:id/gallery {token, on} the host lets the gallery talk, or hushes it +// 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/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 // POST /api/rooms/:id/report {token?, happened, expected} a report to the keeper, pinned to the turn @@ -30,6 +36,9 @@ import { dirname } from 'node:path'; const PORT = Number(process.env.PORT ?? 8788); const HOST = process.env.HOST ?? '127.0.0.1'; const DATA_DIR = process.env.WH_DATA_DIR ?? '../data/rooms'; +/** The keeper of the site, whom a table may call to a seat, and where they sleep. */ +const KEEPER = process.env.KEEPER ?? 'the keeper'; +const KEEPER_TZ = process.env.KEEPER_TZ ?? 'America/New_York'; const BODY_LIMIT = 16 * 1024; // Errors go to Sentry when a DSN is set; the SDK drops them otherwise. @@ -41,7 +50,7 @@ process.on('unhandledRejection', (reason) => { Sentry.captureException(reason); }); -const rooms = new Rooms(new Store(DATA_DIR)); +const rooms = new Rooms(new Store(DATA_DIR), { name: KEEPER, zone: KEEPER_TZ }); /** Reports live beside the room ledgers, not among them. */ const reports = new Reports(dirname(DATA_DIR)); const PUBLIC_URL = (process.env.WH_PUBLIC_URL ?? 'https://hands.kestrelsnest.social').replace(/\/$/, ''); @@ -51,6 +60,12 @@ const doors = new RateLimit(40, 60 * 60 * 1000); const voices = new RateLimit(240, 60 * 60 * 1000); /** Reports, answers and pictures: a few an hour from one address; each rings the keeper's phone. */ const desk = new RateLimit(6, 60 * 60 * 1000); +/** The gallery talks under a tighter rein than the table: no seat, no token, one address. */ +const galleryVoices = new RateLimit(30, 10 * 60 * 1000); +/** Calls to the keeper: a real person's phone rings for each. */ +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; const IDLE_ROOM_MS = 7 * 24 * 60 * 60 * 1000; setInterval(() => { @@ -58,6 +73,9 @@ setInterval(() => { doors.prune(); voices.prune(); desk.prune(); + galleryVoices.prune(); + bells.prune(); + claims.prune(); if (n) console.log(`evicted ${n} idle room${n === 1 ? '' : 's'} from memory; ${rooms.loaded} loaded`); }, 60 * 60 * 1000).unref(); @@ -178,6 +196,11 @@ 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] === 'api' && parts[1] === 'reports') return handleReports(req, res, parts); + if (parts[0] === 'api' && parts[1] === 'transfer' && parts.length === 2 && req.method === 'POST') { + if (!claims.allow(clientOf(req))) throw new RoomError('Too many claims from here just now; try again later.', 429); + const body = await readBody(req); + return send(res, 200, rooms.claim(body.phrase)); + } if (parts[0] !== 'api' || parts[1] !== 'rooms') throw new RoomError('Not here.', 404); if (parts.length === 2 && req.method === 'POST') { @@ -237,12 +260,42 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise return send(res, 201, { id: report.id }); } if (action === 'say') { + const token = typeof body.token === 'string' && body.token ? body.token : undefined; + if (!token) { + if (!galleryVoices.allow(clientOf(req))) throw new RoomError('The gallery has said plenty from here for now.', 429); + rooms.sayFromGallery(room, body.name, body.text); + return send(res, 200, rooms.view(room, SPECTATOR)); + } if (!voices.allow(clientOf(req))) throw new RoomError('The table has heard enough from here for now.', 429); - const token = typeof body.token === 'string' ? body.token : undefined; rooms.say(room, token, body.text); + return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id)); + } + if (action === 'gallery') { + const token = typeof body.token === 'string' ? body.token : undefined; + rooms.setGalleryTalk(room, token, body.on === true); + return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id)); + } + if (action === 'challenge') { + if (!bells.allow(clientOf(req))) throw new RoomError('The keeper has been called enough from here for one hour.', 429); + const token = typeof body.token === 'string' ? body.token : undefined; const seat = rooms.seatOf(room, token); + rooms.callKeeper(room, token, KEEPER); + const link = `${PUBLIC_URL}/join/${room.id}`; + // One issue per table, so each call rings once, with the door in the message. + ringBell(`${seat.name} challenges ${KEEPER} to a duel — ${link}`, { + fingerprint: ['challenge', room.id], + tags: { room: room.id, challenger: seat.name }, + extra: { link, wizards: room.seats.map((s) => s.name).join(', ') } + }); return send(res, 200, rooms.view(room, seat.id)); } + if (action === 'rematch') { + const next = rooms.rematch(room, typeof body.token === 'string' ? body.token : undefined); + return send(res, next.created ? 201 : 200, { roomId: next.roomId, seat: next.seat?.id ?? null, token: next.token, created: next.created }); + } + if (action === 'transfer') { + return send(res, 200, rooms.transfer(room, typeof body.token === 'string' ? body.token : undefined)); + } throw new RoomError('Not here.', 404); } diff --git a/server/src/rooms.ts b/server/src/rooms.ts index c58ecae..d270ed5 100644 --- a/server/src/rooms.ts +++ b/server/src/rooms.ts @@ -37,6 +37,16 @@ export interface Room { size: number; createdAt: number; seats: Seat[]; + /** The host lets the Peanut Gallery talk at this table. */ + galleryTalk: boolean; + /** The keeper of the site was called to a seat, and by whom. */ + challenge: { by: WizardId; at: number } | null; + /** Names with a seat held for them: the wizards of the last table, or the keeper. */ + expected: string[]; + /** A finished table that called for a rematch: where it went, and who called. */ + rematch: { to: string; by: WizardId } | null; + /** The table this one is the rematch of. */ + rematchOf: string | null; state: GameState | null; /** Inputs received for the turn in progress, by seat. */ pending: Record; @@ -70,6 +80,21 @@ function newSeed(): number { return (Date.now() ^ (Math.random() * 0xffffffff)) >>> 0; } +/** Phrases that carry a seat between devices: four plain words, ten minutes, one use. */ +const TRANSFER_WORDS = [ + 'ember', 'raven', 'flagon', 'wand', 'rune', 'moss', 'torch', 'frost', 'amber', 'wisp', 'cellar', 'gable', + 'onyx', 'briar', 'tome', 'cinder', 'gloom', 'spiral', 'mirror', 'lantern', 'thistle', 'harbor', 'quill', 'saddle', + 'willow', 'copper', 'meadow', 'anvil', 'beacon', 'orchard', 'pebble', 'cloister', 'walnut', 'tallow', 'heron', 'marble', + 'ivy', 'kestrel', 'fennel', 'velvet', 'canyon', 'lattice', 'ledger', 'bramble', 'sable', 'tundra', 'juniper', 'compass' +]; +const TRANSFER_TTL_MS = 10 * 60 * 1000; +const TRANSFER_FAIL_LIMIT = 20; + +/** A table with nobody seated yet. */ +function blank(id: string, size: number, createdAt: number, rematchOf: string | null, expected: string[], seq = 0): Room { + return { id, size, createdAt, seats: [], galleryTalk: false, challenge: null, expected, rematch: null, rematchOf, state: null, pending: {}, chat: [], seq, updatedAt: createdAt }; +} + export function normalizeCode(raw: string): string { return raw.trim().toUpperCase(); } @@ -80,7 +105,11 @@ export class Rooms { /** Sockets watching from the gallery, by room. */ private gallery = new Map(); - constructor(private store: Store) { + constructor( + private store: Store, + /** The keeper of the site: the name a table may call to a seat, and where they live. */ + private keeper: { name: string; zone: string } = { name: 'the keeper', zone: 'UTC' } + ) { for (const id of store.roomIds()) { const room = replay(id, store.read(id)); if (room) this.rooms.set(id, room); @@ -129,11 +158,16 @@ export class Rooms { create(name: string, size: number): { room: Room; seat: Seat; token: string } { if (!Number.isInteger(size) || size < 2 || size > MAX_SEATS) throw new RoomError(`A table seats 2 to ${MAX_SEATS} wizards.`); + return this.open(name, size); + } + + /** Open a table with its first seat taken; a rematch names the table it follows and holds seats for its wizards. */ + private open(name: string, size: number, follows?: { rematchOf: string; expected: string[] }): { room: Room; seat: Seat; token: string } { let id = newCode(); while (this.rooms.has(id) || this.store.exists(id)) id = newCode(); - const room: Room = { id, size, createdAt: Date.now(), seats: [], state: null, pending: {}, chat: [], seq: 0, updatedAt: Date.now() }; + const room = blank(id, size, Date.now(), follows?.rematchOf ?? null, follows?.expected ?? []); this.rooms.set(id, room); - this.commit(room, { t: 'room', id, seats: size, createdAt: room.createdAt }); + this.commit(room, { t: 'room', id, seats: size, createdAt: room.createdAt, ...(follows ? { rematchOf: follows.rematchOf, expected: follows.expected } : {}) }); return { room, ...this.sit(room, name, false) }; } @@ -142,6 +176,99 @@ export class Rooms { return this.sit(room, name, false); } + /** Names with a seat held who have not yet sat. */ + private awaited(room: Room): string[] { + return room.expected.filter((n) => !room.seats.some((s) => s.name.toLowerCase() === n.toLowerCase())); + } + + /** Host only: let the Peanut Gallery talk at this table, or hush it. Written to the ledger. */ + setGalleryTalk(room: Room, token: string | undefined, on: boolean): void { + const seat = this.seatOf(room, token); + if (seat.id !== room.seats[0]?.id) throw new RoomError('Only the wizard who opened the table decides whether the gallery may talk.', 403); + if (room.galleryTalk === on) return; + this.commit(room, { t: 'galleryTalk', at: Date.now(), on, by: seat.id }); + } + + /** A word from the Peanut Gallery, when the host allows it: signed with a name no seat holds. */ + sayFromGallery(room: Room, rawName: unknown, raw: unknown): void { + if (!room.galleryTalk) throw new RoomError('The gallery listens at this table; the host has not let it talk.', 403); + const name = cleanName(rawName); + if (room.seats.some((s) => s.name.toLowerCase() === name.toLowerCase())) throw new RoomError('A wizard at the table has that name.', 409); + this.commit(room, { t: 'chat', at: Date.now(), id: SPECTATOR, name, text: cleanTalk(raw) }); + } + + /** + * A seated wizard calls the keeper of the site to the table: a seat is + * held under the keeper's name and the call goes on the ledger. Once per + * table, before the duel begins. The caller rings the keeper's bell. + */ + callKeeper(room: Room, token: string | undefined, keeper: string): void { + const seat = this.seatOf(room, token); + if (room.state) throw new RoomError('The duel has begun.', 409); + if (room.challenge) throw new RoomError(`${keeper} has already been called to this table.`, 409); + if (room.seats.some((s) => s.name.toLowerCase() === keeper.toLowerCase())) throw new RoomError(`${keeper} is already here.`, 409); + if (room.seats.length + this.awaited(room).length >= room.size) throw new RoomError('Every seat at this table is taken or held.', 409); + this.commit(room, { t: 'challenge', at: Date.now(), by: seat.id, keeper }); + } + + /** + * A finished table calls for a rematch: a new table of the same size with + * the same bots, seats held for the same people, the caller seated as its + * host. Anyone at the old table may call; a second call finds the table + * already open. + */ + rematch(room: Room, token: string | undefined): { roomId: string; seat: Seat | null; token: string | null; created: boolean } { + 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); + 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); + 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(); + 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); + 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); + let phrase: string; + do { + 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 }); + return { phrase, expiresAt }; + } + + /** 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(); + if (now - this.failWindowStart > TRANSFER_TTL_MS) { + this.failWindowStart = now; + this.failedClaims = 0; + } + if (this.failedClaims >= TRANSFER_FAIL_LIMIT) throw new RoomError('Too many failed claims; transfers are cooling off. Mint a fresh phrase.', 429); + const phrase = String(raw ?? '').trim().toLowerCase().replace(/\s+/g, '-'); + const t = this.transfers.get(phrase); + if (!t || t.expiresAt < now) { + this.failedClaims += 1; + if (this.failedClaims >= TRANSFER_FAIL_LIMIT) this.transfers.clear(); + throw new RoomError('That phrase is unknown or has expired.', 404); + } + this.transfers.delete(phrase); + const room = this.get(t.roomId); + const seat = this.seatOf(room, t.token); + return { roomId: room.id, seat: seat.id, token: t.token }; + } + /** The host, who opened the table, begins the duel once at least two are seated, however full the table. */ begin(room: Room, token: string | undefined): void { const seat = this.seatOf(room, token); @@ -166,6 +293,10 @@ export class Rooms { private sit(room: Room, rawName: string, bot: boolean): { seat: Seat; token: string } { if (room.seats.length >= room.size) throw new RoomError('Every seat at this duel is taken.', 409); const name = cleanName(rawName); + const awaited = this.awaited(room); + if (!awaited.some((n) => n.toLowerCase() === name.toLowerCase()) && room.seats.length + awaited.length >= room.size) { + throw new RoomError('The seats left are held for wizards expected at this table.', 409); + } if (room.seats.some((s) => s.name.toLowerCase() === name.toLowerCase())) throw new RoomError('Another wizard here already has that name.', 409); const id = SEAT_IDS.find((s) => !room.seats.some((taken) => taken.id === s))!; const token = bot ? '' : newId(18); @@ -212,13 +343,7 @@ export class Rooms { /** Table talk, from a seat to everyone at the table and in the gallery. */ say(room: Room, token: string | undefined, raw: unknown): void { const seat = this.seatOf(room, token); - const text = String(raw ?? '') - .replace(/[\u0000-\u001f\u007f]/g, ' ') - .replace(/\s+/g, ' ') - .trim() - .slice(0, CHAT_MAX_LENGTH); - if (!text) throw new RoomError('Nothing to say.'); - this.commit(room, { t: 'chat', at: Date.now(), id: seat.id, text }); + this.commit(room, { t: 'chat', at: Date.now(), id: seat.id, text: cleanTalk(raw) }); } /** The view for a seat, or for the gallery when the seat is SPECTATOR. */ @@ -233,7 +358,14 @@ export class Rooms { waitingOn: waitingOn(room), state: room.state ? viewFor(room.state, seatId) : null, chat: room.chat, - audience: this.gallery.get(room.id) ?? 0 + audience: this.gallery.get(room.id) ?? 0, + galleryTalk: room.galleryTalk, + challenge: room.challenge, + expected: this.awaited(room), + rematch: room.rematch, + rematchOf: room.rematchOf, + keeper: this.keeper.name, + keeperZone: this.keeper.zone }; } @@ -298,6 +430,16 @@ function apply(room: Room, line: LedgerLine): void { case 'input': room.pending[line.id] = line.input; break; + case 'galleryTalk': + room.galleryTalk = line.on; + break; + case 'challenge': + room.challenge = { by: line.by, at: line.at }; + if (!room.expected.some((n) => n.toLowerCase() === line.keeper.toLowerCase())) room.expected = [...room.expected, line.keeper]; + break; + case 'rematch': + room.rematch = { to: line.to, by: line.by }; + break; case 'unseat': room.seats = room.seats.filter((s) => s.id !== line.id); break; @@ -312,7 +454,7 @@ function apply(room: Room, line: LedgerLine): void { case 'over': break; case 'chat': - room.chat.push({ id: line.id, text: line.text, at: line.at }); + room.chat.push({ id: line.id, text: line.text, at: line.at, ...(line.name ? { name: line.name } : {}) }); if (room.chat.length > CHAT_KEEP) room.chat.splice(0, room.chat.length - CHAT_KEEP); break; } @@ -321,7 +463,7 @@ function apply(room: Room, line: LedgerLine): void { function replay(id: string, lines: LedgerLine[]): Room | null { const first = lines[0]; if (!first || first.t !== 'room') return null; - const room: Room = { id, size: first.seats, createdAt: first.createdAt, seats: [], state: null, pending: {}, chat: [], seq: lines.length, updatedAt: first.createdAt }; + const room = blank(id, first.seats, first.createdAt, first.rematchOf ?? null, first.expected ?? [], lines.length); for (const line of lines) { apply(room, line); if (line.t === 'turn' || line.t === 'input') room.updatedAt = line.at; @@ -329,6 +471,16 @@ function replay(id: string, lines: LedgerLine[]): Room | null { return room; } +function cleanTalk(raw: unknown): string { + const text = String(raw ?? '') + .replace(/[\u0000-\u001f\u007f]/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .slice(0, CHAT_MAX_LENGTH); + if (!text) throw new RoomError('Nothing to say.'); + return text; +} + export function cleanName(raw: unknown): string { const name = String(raw ?? '') .replace(/\s+/g, ' ') diff --git a/server/src/store.ts b/server/src/store.ts index 39fd48a..aa8df8a 100644 --- a/server/src/store.ts +++ b/server/src/store.ts @@ -7,7 +7,8 @@ import { join } from 'node:path'; import type { TurnInput, WizardId } from '../../src/lib/game/state'; export type LedgerLine = - | { t: 'room'; id: string; seats: number; createdAt: number } + /** `rematchOf` and `expected` mark a table opened for a rematch: whose it follows, and whose seats are held. */ + | { 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 } /** One seat's move for the turn in progress, so a restart keeps it. The turn line that follows carries every input again. */ @@ -19,8 +20,14 @@ export type LedgerLine = | { t: 'turn'; at: number; inputs: Record } /** Written after the turn that ends the duel, for anything that reads ledgers without the engine. */ | { t: 'over'; at: number; winner: WizardId | null; reason: string } - /** A line of table talk from a seated wizard. */ - | { t: 'chat'; at: number; id: WizardId; text: string }; + /** A line of table talk: from a seat, or, with `name`, from the Peanut Gallery (id SPECTATOR). */ + | { t: 'chat'; at: number; id: WizardId; text: string; name?: string } + /** The host let the gallery talk, or hushed it. */ + | { t: 'galleryTalk'; at: number; on: boolean; by: WizardId } + /** 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 }; export class Store { constructor(private dir: string) { diff --git a/src/lib/components/Hall.svelte b/src/lib/components/Hall.svelte index 7fb70fe..3a5809f 100644 --- a/src/lib/components/Hall.svelte +++ b/src/lib/components/Hall.svelte @@ -1,6 +1,7 @@ {#if remote} @@ -36,11 +49,17 @@
  • Nobody has said a word.
  • {/if} {#each lines as line, i (line.at + ':' + i)} -
  • {line.id === duel.me ? 'You' : duel.speaker(line.id)} {line.text}
  • +
  • {line.name ? `${line.name} (gallery)` : line.id === duel.me ? 'You' : duel.speaker(line.id)} {line.text}
  • {/each} - {#if remote.spectating} -

    The gallery listens; only seated wizards speak.

    + {#if remote.spectating && remote.galleryTalk} +
    + + + +
    + {:else if remote.spectating} +

    The gallery listens; the host has not let it talk.

    {:else}
    @@ -105,6 +124,10 @@ min-width: 0; } + input.name { + flex: 0 1 8rem; + } + .quiet { background: none; border: 1px solid var(--rule-strong); diff --git a/src/lib/game/client.ts b/src/lib/game/client.ts index 4a62108..04a7508 100644 --- a/src/lib/game/client.ts +++ b/src/lib/game/client.ts @@ -38,6 +38,54 @@ export function forgetSeat(roomId: string): void { } } +/** + * 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 + * is not deletion. Only the SECOND refusal in a row costs the seat. + */ +const DOUBT_KEY = 'wh:doubted'; + +function doubted(): Record { + try { + return JSON.parse(localStorage.getItem(DOUBT_KEY) ?? '{}') as Record; + } catch { + return {}; + } +} + +/** Note a refusal; true when it is the second in a row and the seat is forgotten. */ +export function doubtSeat(roomId: string): boolean { + const all = doubted(); + if (all[roomId]) { + forgetSeat(roomId); + delete all[roomId]; + try { + localStorage.setItem(DOUBT_KEY, JSON.stringify(all)); + } catch { + // Nothing to note without storage. + } + return true; + } + try { + localStorage.setItem(DOUBT_KEY, JSON.stringify({ ...all, [roomId]: true })); + } catch { + // Nothing to note without storage. + } + return false; +} + +/** The server answered for this seat: any doubt is cleared. */ +export function trustSeat(roomId: string): void { + const all = doubted(); + if (!all[roomId]) return; + delete all[roomId]; + try { + localStorage.setItem(DOUBT_KEY, JSON.stringify(all)); + } catch { + // Nothing to clear without storage. + } +} + export function rememberSeat(roomId: string, held: HeldSeat): void { try { localStorage.setItem(SEATS_KEY, JSON.stringify({ ...heldSeats(), [roomId]: held })); @@ -93,6 +141,14 @@ export const api = { watch: (roomId: string) => call('GET', `/api/rooms/${roomId}`), turn: (roomId: string, token: string, input: TurnInput) => call('POST', `/api/rooms/${roomId}/turn`, { token, input }), say: (roomId: string, token: string, text: string) => call('POST', `/api/rooms/${roomId}/say`, { token, text }), + /** A word from the gallery, signed with a name, when the host allows it. */ + sayFromGallery: (roomId: string, name: string, text: string) => call('POST', `/api/rooms/${roomId}/say`, { name, text }), + setGalleryTalk: (roomId: string, token: string, on: boolean) => call('POST', `/api/rooms/${roomId}/gallery`, { token, on }), + challenge: (roomId: string, token: string) => call('POST', `/api/rooms/${roomId}/challenge`, { token }), + rematch: (roomId: string, token: string) => + call<{ roomId: string; seat: WizardId | null; token: string | null; created: boolean }>('POST', `/api/rooms/${roomId}/rematch`, { token }), + transfer: (roomId: string, token: string) => call<{ phrase: string; expiresAt: number }>('POST', `/api/rooms/${roomId}/transfer`, { token }), + claim: (phrase: string) => call<{ roomId: string; seat: WizardId; token: string }>('POST', '/api/transfer', { phrase }), 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/lib/game/duel.svelte.ts b/src/lib/game/duel.svelte.ts index 3bae11f..b910fed 100644 --- a/src/lib/game/duel.svelte.ts +++ b/src/lib/game/duel.svelte.ts @@ -222,8 +222,15 @@ export interface Remote extends HeldSeat { chat: ChatLine[]; /** How many watch from the Peanut Gallery. */ audience: number; - /** Watching from the gallery: no seat, no hands, no voice. */ + /** Watching from the gallery: no seat, no hands, no voice unless the host allows it. */ spectating: boolean; + galleryTalk: boolean; + challenge: RoomView['challenge']; + expected: string[]; + rematch: RoomView['rematch']; + rematchOf: string | null; + keeper: string; + keeperZone: string; } export class Duel { @@ -786,6 +793,49 @@ export class Duel { return this.remote?.seats.find((s) => s.id === id)?.name ?? this.state.wizards[id]?.name ?? id; } + private async remoteAct(fn: (r: Remote) => Promise, failure: string): Promise { + const r = this.remote; + if (!r) return false; + r.error = ''; + try { + this.apply(await fn(r)); + return true; + } catch (e) { + r.error = e instanceof Error ? e.message : failure; + return false; + } + } + + /** A word from the gallery, signed; only when the host has let the gallery talk. */ + sayFromGallery(name: string, text: string): Promise { + if (!this.remote?.spectating) return Promise.resolve(false); + return this.remoteAct((r) => api.sayFromGallery(r.roomId, name, text), 'The table did not hear you.'); + } + + /** Host only: let the Peanut Gallery talk at this table, or hush it. */ + setGalleryTalk(on: boolean): Promise { + return this.remoteAct((r) => api.setGalleryTalk(r.roomId, r.token, on), 'The gallery could not be told.'); + } + + /** Call the keeper of the site to a held seat; their phone rings. */ + callKeeper(): Promise { + return this.remoteAct((r) => api.challenge(r.roomId, r.token), 'The keeper could not be called.'); + } + + /** A finished table opens another with the same company; the caller is seated as its host. */ + async rematch(): Promise { + const r = this.remote!; + const next = await api.rematch(r.roomId, r.token); + if (next.created && next.seat && next.token) rememberSeat(next.roomId, { seat: next.seat, token: next.token }); + return next.roomId; + } + + /** A phrase that brings this seat to another device, good for ten minutes. */ + transfer(): Promise<{ phrase: string; expiresAt: number }> { + const r = this.remote!; + return api.transfer(r.roomId, r.token); + } + /** Say something to the whole table. Resolves false when the words did not arrive. */ async say(text: string): Promise { const r = this.remote; @@ -809,6 +859,11 @@ export class Duel { r.waitingOn = view.waitingOn; r.chat = view.chat; r.audience = view.audience; + r.galleryTalk = view.galleryTalk; + r.challenge = view.challenge; + r.expected = view.expected; + r.rematch = view.rematch; + r.rematchOf = view.rematchOf; markTalkSeen(r.roomId, view.chat.length); const wasStarted = r.started; r.started = view.state !== null; @@ -960,7 +1015,14 @@ function remoteFrom(view: RoomView, token: string): Remote { error: '', chat: view.chat, audience: view.audience, - spectating: view.me === SPECTATOR + spectating: view.me === SPECTATOR, + galleryTalk: view.galleryTalk, + challenge: view.challenge, + expected: view.expected, + rematch: view.rematch, + rematchOf: view.rematchOf, + keeper: view.keeper, + keeperZone: view.keeperZone }; } diff --git a/src/lib/game/view.ts b/src/lib/game/view.ts index 28b873f..86d034f 100644 --- a/src/lib/game/view.ts +++ b/src/lib/game/view.ts @@ -15,11 +15,12 @@ export function viewFor(state: GameState, viewer: WizardId): GameState { return view; } -/** One line of table talk. Only seated wizards speak; the gallery listens. */ +/** One line of table talk: from a seat, or from the Peanut Gallery when the host allows it (id SPECTATOR, signed with `name`). */ export interface ChatLine { id: WizardId; text: string; at: number; + name?: string; } /** What a seated player, or the gallery, is told by the server. Other seats' moves and tokens never leave it. */ @@ -39,4 +40,17 @@ export interface RoomView { chat: ChatLine[]; /** How many watch from the Peanut Gallery. */ audience: number; + /** The host lets the gallery talk. */ + galleryTalk: boolean; + /** The keeper of the site was called to a seat, and by whom. */ + challenge: { by: WizardId; at: number } | null; + /** Names with a seat held who have not yet sat: the last table's wizards, or the keeper. */ + expected: string[]; + /** A finished table's rematch: where it went, and who called. */ + rematch: { to: string; by: WizardId } | null; + /** The table this one is the rematch of. */ + rematchOf: string | null; + /** The keeper of the site, and where they live, for a table that calls them. */ + keeper: string; + keeperZone: string; } diff --git a/src/routes/guide/+page.svelte b/src/routes/guide/+page.svelte index 3750ae1..2e12891 100644 --- a/src/routes/guide/+page.svelte +++ b/src/routes/guide/+page.svelte @@ -101,7 +101,8 @@
    diff --git a/src/routes/join/[code]/+page.svelte b/src/routes/join/[code]/+page.svelte index c4b63cc..d4396bb 100644 --- a/src/routes/join/[code]/+page.svelte +++ b/src/routes/join/[code]/+page.svelte @@ -1,4 +1,5 @@