Gallery talk, the keeper's bell, rematches, seats that travel by phrase, and seats doubted before dropped
Brought over from the game kit, where they were ported from Wiz-War. The host may let the Peanut Gallery talk; a watcher signs a name no seat holds. A seated wizard may challenge Kestrel, the keeper: a seat is held, the call rings Sentry once per table, and the lobby says what hour it is where the keeper lives. A finished table may call for a rematch with the same company and bots, seats held. Transfer seat mints four words good for ten minutes and one use; the hall claims them. The hall forgets a held seat only on the second refusal in a row. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
5b169d7131
commit
e5c240d970
@@ -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
|
||||
|
||||
+56
-3
@@ -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<void>
|
||||
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<void>
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
+165
-13
@@ -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<WizardId, TurnInput>;
|
||||
@@ -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<string, number>();
|
||||
|
||||
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<string, { roomId: string; seatId: WizardId; token: string; 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);
|
||||
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, ' ')
|
||||
|
||||
+10
-3
@@ -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<WizardId, TurnInput> }
|
||||
/** 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) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { allSeats, api, forgetSeat, type Report } from '$lib/game/client';
|
||||
import { otherGames, type FamilyGame } from '$lib/family';
|
||||
import { allSeats, api, doubtSeat, forgetSeat, rememberSeat, ServerError, trustSeat, type Report } 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';
|
||||
@@ -12,6 +13,11 @@
|
||||
let seats = $state(allSeats());
|
||||
let games = $state<Record<string, RoomView | null>>({});
|
||||
let aboutOpen = $state(false);
|
||||
/** The other games of the Kestrel's Nest, for the about panel. */
|
||||
let family = $state<FamilyGame[]>([]);
|
||||
$effect(() => {
|
||||
void otherGames('waving-hands').then((g) => (family = g));
|
||||
});
|
||||
/** Reports this browser's seats have filed, with the keeper's replies. */
|
||||
let reports = $state<Report[]>([]);
|
||||
let answers = $state<Record<string, string>>({});
|
||||
@@ -44,21 +50,44 @@
|
||||
const local = $derived(duel.inProgress ? duel : null);
|
||||
const ready = $derived(name.trim().length > 0);
|
||||
|
||||
// Each held seat is looked up once, so the ledger can say whose move it is.
|
||||
// Each held seat is looked up once, so the ledger can say whose move it
|
||||
// is. A seat the server refuses outright is doubted; a second refusal in
|
||||
// a row forgets it. Silence (a restart, a stale answer) forgets nothing.
|
||||
$effect(() => {
|
||||
for (const held of seats) {
|
||||
if (held.roomId in games) continue;
|
||||
games[held.roomId] = null;
|
||||
api.view(held.roomId, held.token)
|
||||
.then((v) => {
|
||||
trustSeat(held.roomId);
|
||||
games[held.roomId] = v;
|
||||
})
|
||||
.catch(() => {
|
||||
.catch((e: unknown) => {
|
||||
games[held.roomId] = null;
|
||||
if (e instanceof ServerError && (e.status === 403 || e.status === 404) && doubtSeat(held.roomId)) seats = allSeats();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let phrase = $state('');
|
||||
let claiming = $state(false);
|
||||
|
||||
/** Bring a seat from another device by the phrase it minted. */
|
||||
async function claim(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
claiming = true;
|
||||
error = '';
|
||||
try {
|
||||
const got = await api.claim(phrase.trim());
|
||||
rememberSeat(got.roomId, { seat: got.seat, token: got.token });
|
||||
await goto(`/join/${got.roomId}`);
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'The phrase opened no seat.';
|
||||
} finally {
|
||||
claiming = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function playBot() {
|
||||
busy = true;
|
||||
error = '';
|
||||
@@ -170,6 +199,11 @@
|
||||
</form>
|
||||
</div>
|
||||
<p class="muted small">A code opens the door either way: take a seat if one is free, or watch from the Peanut Gallery.</p>
|
||||
<form class="joinform claimform" onsubmit={claim}>
|
||||
<span class="or">or bring a seat from another device</span>
|
||||
<input type="text" bind:value={phrase} placeholder="the four-word phrase" autocomplete="off" aria-label="transfer phrase" />
|
||||
<button type="submit" class="quiet" disabled={claiming || phrase.trim().split(/[\s-]+/).length < 4}>Claim</button>
|
||||
</form>
|
||||
{#if error}<p class="warning">{error}</p>{/if}
|
||||
</div>
|
||||
|
||||
@@ -256,6 +290,14 @@
|
||||
<p>It is free and keeps no accounts. A duel against the bot lives in your own browser. A duel between people lives on a small server as an append-only ledger of moves, so it can be replayed from its first gesture, and each wizard is shown only what the rules let them see.</p>
|
||||
<h3>Send word</h3>
|
||||
<p>A rule read wrong, a bug, a duel you would like to tell of: write to <a href="mailto:eric@ericwagoner.com">eric@ericwagoner.com</a>, <a href="https://bsky.app/profile/kestrelsnest.social" target="_blank" rel="noreferrer">@kestrelsnest.social</a> on Bluesky, or <a href="https://toots.kestrelsnest.social/@eric" target="_blank" rel="noreferrer">@eric@toots.kestrelsnest.social</a> on Mastodon. The keeper of this hall roosts at <a href="https://kestrelsnest.social" target="_blank" rel="noreferrer">kestrelsnest.social</a>.</p>
|
||||
{#if family.length}
|
||||
<h3>More games at the Kestrel's Nest</h3>
|
||||
<ul class="family">
|
||||
{#each family as g (g.slug)}
|
||||
<li><a href={g.url}>{g.name}</a>: {g.pitch} <span class="muted">{g.players} players; {g.origin}.</span></li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
<p class="made">Waving Hands is Richard Bartle's, 1977. This page was made by Eric and a very enthusiastic AI, 2026.</p>
|
||||
</div>
|
||||
</details>
|
||||
@@ -367,6 +409,15 @@
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.claimform {
|
||||
flex-wrap: wrap;
|
||||
margin-top: -0.2rem;
|
||||
}
|
||||
|
||||
.claimform input {
|
||||
width: 15em;
|
||||
}
|
||||
|
||||
.or {
|
||||
color: var(--bone-dim);
|
||||
font-style: italic;
|
||||
@@ -620,4 +671,12 @@
|
||||
.about-body a {
|
||||
color: var(--frost);
|
||||
}
|
||||
.family {
|
||||
margin: 0.4rem 0 0.8rem 1.2rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.family li + li {
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import type { Duel } from '$lib/game/duel.svelte';
|
||||
import { NAME_MAX, playerName, rememberName, type Duel } from '$lib/game/duel.svelte';
|
||||
|
||||
let { duel }: { duel: Duel } = $props();
|
||||
|
||||
const remote = $derived(duel.remote);
|
||||
const lines = $derived(remote?.chat ?? []);
|
||||
let draft = $state('');
|
||||
/** The gallery signs its lines; the name is the one the hall remembers. */
|
||||
let galleryName = $state(playerName());
|
||||
let sending = $state(false);
|
||||
let list = $state<HTMLElement | null>(null);
|
||||
|
||||
@@ -26,6 +28,17 @@
|
||||
if (await duel.say(text)) draft = '';
|
||||
sending = false;
|
||||
}
|
||||
|
||||
async function sayFromGallery(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
const text = draft.trim();
|
||||
const name = galleryName.trim();
|
||||
if (!text || !name || sending) return;
|
||||
sending = true;
|
||||
rememberName(name);
|
||||
if (await duel.sayFromGallery(name, text)) draft = '';
|
||||
sending = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if remote}
|
||||
@@ -36,11 +49,17 @@
|
||||
<li class="muted">Nobody has said a word.</li>
|
||||
{/if}
|
||||
{#each lines as line, i (line.at + ':' + i)}
|
||||
<li class:you={line.id === duel.me}><em>{line.id === duel.me ? 'You' : duel.speaker(line.id)}</em> {line.text}</li>
|
||||
<li class:you={!line.name && line.id === duel.me}><em>{line.name ? `${line.name} (gallery)` : line.id === duel.me ? 'You' : duel.speaker(line.id)}</em> {line.text}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if remote.spectating}
|
||||
<p class="muted small">The gallery listens; only seated wizards speak.</p>
|
||||
{#if remote.spectating && remote.galleryTalk}
|
||||
<form onsubmit={sayFromGallery}>
|
||||
<input type="text" class="name" bind:value={galleryName} maxlength={NAME_MAX} placeholder="Your name" aria-label="Your name in the gallery" autocomplete="nickname" />
|
||||
<input type="text" bind:value={draft} maxlength="300" placeholder="Say something from the gallery" aria-label="Say something from the gallery" autocomplete="off" />
|
||||
<button type="submit" class="quiet" disabled={!draft.trim() || !galleryName.trim() || sending}>Say</button>
|
||||
</form>
|
||||
{:else if remote.spectating}
|
||||
<p class="muted small">The gallery listens; the host has not let it talk.</p>
|
||||
{:else}
|
||||
<form onsubmit={say}>
|
||||
<input type="text" bind:value={draft} maxlength="300" placeholder="Say something to the table" aria-label="Say something to the table" autocomplete="off" />
|
||||
@@ -105,6 +124,10 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
input.name {
|
||||
flex: 0 1 8rem;
|
||||
}
|
||||
|
||||
.quiet {
|
||||
background: none;
|
||||
border: 1px solid var(--rule-strong);
|
||||
|
||||
@@ -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<string, true> {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(DOUBT_KEY) ?? '{}') as Record<string, true>;
|
||||
} 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<RoomView>('GET', `/api/rooms/${roomId}`),
|
||||
turn: (roomId: string, token: string, input: TurnInput) => call<RoomView>('POST', `/api/rooms/${roomId}/turn`, { token, input }),
|
||||
say: (roomId: string, token: string, text: string) => call<RoomView>('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<RoomView>('POST', `/api/rooms/${roomId}/say`, { name, text }),
|
||||
setGalleryTalk: (roomId: string, token: string, on: boolean) => call<RoomView>('POST', `/api/rooms/${roomId}/gallery`, { token, on }),
|
||||
challenge: (roomId: string, token: string) => call<RoomView>('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) => {
|
||||
|
||||
@@ -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<RoomView>, failure: string): Promise<boolean> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<string> {
|
||||
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<boolean> {
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+15
-1
@@ -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;
|
||||
}
|
||||
|
||||
@@ -101,7 +101,8 @@
|
||||
<section id="gallery">
|
||||
<h2>The Peanut Gallery and table talk</h2>
|
||||
<p>A room's link is an invitation to sit while the table is laid and to watch once the duel has begun. Anyone who opens it without a seat takes a place in the Peanut Gallery: they see the ledger, the result of each turn and the chronicle, and the table is told how many are watching. The gallery is shown only what every wizard at the table could see; a gesture hidden from any one of them is a question mark to everyone in the gallery, and the turn being written shows nothing at all.</p>
|
||||
<p><em>Table talk</em>, in the rail, is for the wizards seated. A line goes to everyone at the table and everyone in the gallery, and it is kept with the duel; when you come back to a table, the hall says how many lines were said while you were away. The gallery listens and does not speak.</p>
|
||||
<p><em>Table talk</em>, in the rail, is for the wizards seated. A line goes to everyone at the table and everyone in the gallery, and it is kept with the duel; when you come back to a table, the hall says how many lines were said while you were away. The gallery listens, unless the host lets it talk; then a watcher signs a name and their lines read <em>Name (gallery)</em>.</p>
|
||||
<p>Three more things a table can do. While it is laid, any seated wizard may <strong>challenge the keeper</strong>: a seat is held under the keeper's name, the keeper's phone rings, and the note beside the button says what hour it is where they live, so you know whether to wait or come back. When a duel is over, anyone at the table may call for <strong>a rematch</strong>: a new table of the same size with the same bots, seats held for everyone who was here. And <strong>Transfer seat</strong> in the masthead mints four words that bring your seat to another device, good for ten minutes; the hall on that device has a place to claim them.</p>
|
||||
</section>
|
||||
|
||||
<section id="first">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import Board from '$lib/components/Board.svelte';
|
||||
import ReportSlip from '$lib/components/ReportSlip.svelte';
|
||||
@@ -15,6 +16,54 @@
|
||||
let error = $state('');
|
||||
let copied = $state(false);
|
||||
let reporting = $state(false);
|
||||
/** A phrase minted to carry this seat to another device, while it lasts. */
|
||||
let phrase = $state<{ phrase: string; expiresAt: number } | null>(null);
|
||||
let rematching = $state(false);
|
||||
|
||||
/** The hour where the keeper lives, in words, so a caller knows whether to wait. */
|
||||
let minute = $state(0);
|
||||
$effect(() => {
|
||||
const id = setInterval(() => (minute += 1), 60_000);
|
||||
return () => clearInterval(id);
|
||||
});
|
||||
const keeperHour = $derived.by(() => {
|
||||
void minute;
|
||||
const zone = room?.remote?.keeperZone;
|
||||
if (!zone) return '';
|
||||
try {
|
||||
const hour = Number(new Intl.DateTimeFormat('en-US', { hour: 'numeric', hour12: false, timeZone: zone }).format(new Date())) % 24;
|
||||
if (hour === 0) return 'midnight';
|
||||
if (hour === 12) return 'noon';
|
||||
const word = ['twelve', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten', 'eleven'][hour % 12];
|
||||
const part = hour < 5 ? 'in the small hours' : hour < 12 ? 'in the morning' : hour < 18 ? 'in the afternoon' : 'in the evening';
|
||||
return `${word} ${part}`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
});
|
||||
|
||||
async function transfer() {
|
||||
if (!room) return;
|
||||
try {
|
||||
phrase = await room.transfer();
|
||||
setTimeout(() => (phrase = null), Math.max(0, phrase.expiresAt - Date.now()));
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'The seat could not be offered.';
|
||||
}
|
||||
}
|
||||
|
||||
/** Call for a rematch, or take the seat held at one already called. */
|
||||
async function rematch() {
|
||||
if (!room) return;
|
||||
rematching = true;
|
||||
try {
|
||||
await goto(`/join/${await room.rematch()}`);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'The rematch could not be called.';
|
||||
} finally {
|
||||
rematching = false;
|
||||
}
|
||||
}
|
||||
|
||||
const link = $derived(typeof location === 'undefined' ? '' : `${location.origin}/join/${roomId}`);
|
||||
const lobby = $derived(room !== null && !room.remote?.started);
|
||||
@@ -115,7 +164,10 @@
|
||||
<span class="muted">{watching ? 'watching · room' : 'invite friends · room'}</span> <b>{roomId}</b>{copied ? ' · link copied' : ''}
|
||||
</button>
|
||||
{#if room.remote && room.remote.audience > 0}
|
||||
<span class="audience" title="the Peanut Gallery">{room.remote.audience} in the gallery</span>
|
||||
<span class="audience" title="the Peanut Gallery">{room.remote.audience} in the gallery{room.remote.galleryTalk ? ', talking' : ''}</span>
|
||||
{/if}
|
||||
{#if !watching}
|
||||
<button type="button" class="quiet" title="A phrase that brings this seat to another device" onclick={transfer}>Transfer seat</button>
|
||||
{/if}
|
||||
{/if}
|
||||
<a class="quiet" href="/guide">How to play</a>
|
||||
@@ -135,6 +187,23 @@
|
||||
<p class="notice">{error}</p>
|
||||
{/if}
|
||||
|
||||
{#if phrase}
|
||||
<p class="notice muted">On the other device, open the hall and claim this phrase within ten minutes: <b class="phrase">{phrase.phrase}</b></p>
|
||||
{/if}
|
||||
|
||||
{#if room?.remote?.started && room.state.over && !watching}
|
||||
{@const r = room.remote}
|
||||
<p class="notice muted">
|
||||
{#if r.rematch}
|
||||
{room.speaker(r.rematch.by)} called for a rematch at table {r.rematch.to}.
|
||||
<a href={`/join/${r.rematch.to}`}>Take your seat</a>
|
||||
{:else}
|
||||
<button type="button" class="quiet" disabled={rematching} onclick={rematch}>Duel again with this table</button>
|
||||
<span>the same company, at a new table; seats are held for everyone here.</span>
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if joining && seatFree}
|
||||
<section class="lobby">
|
||||
<p class="eyebrow">room {roomId}</p>
|
||||
@@ -176,8 +245,17 @@
|
||||
<button type="button" class="unseat" title="Send this bot away" aria-label="Send {s.name} away" onclick={() => room?.unseat(s.id)}>×</button>{/if}</span>
|
||||
</li>
|
||||
{/each}
|
||||
<li class="empty muted">{r.size - r.seats.length} of {r.size} seats empty{#if r.audience > 0}; {r.audience} in the gallery{/if}</li>
|
||||
{#each r.expected as name (name)}
|
||||
<li>
|
||||
<span class="seat-name">{name}</span>
|
||||
<span class="muted">{r.challenge && name.toLowerCase() === r.keeper.toLowerCase() ? 'called to the table; a seat is held' : 'from the last table; a seat is held'}</span>
|
||||
</li>
|
||||
{/each}
|
||||
<li class="empty muted">{r.size - r.seats.length - r.expected.length} of {r.size} seats empty{#if r.audience > 0}; {r.audience} in the gallery{r.galleryTalk ? ', allowed to talk' : ''}{/if}</li>
|
||||
</ul>
|
||||
{#if r.rematchOf}
|
||||
<p class="muted">A rematch of table {r.rematchOf}: the same company, seats held for those not yet back.</p>
|
||||
{/if}
|
||||
{#if !watching}
|
||||
<div class="ways">
|
||||
{#if r.seats.length < r.size && room.isHost}
|
||||
@@ -190,6 +268,20 @@
|
||||
<span class="muted">{hostName} will begin the duel when the table is ready.</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if room.isHost}
|
||||
<label class="switch muted">
|
||||
<input type="checkbox" checked={r.galleryTalk} onchange={(e) => room?.setGalleryTalk(e.currentTarget.checked)} />
|
||||
Let the Peanut Gallery talk at the table
|
||||
</label>
|
||||
{/if}
|
||||
{#if r.keeper && !r.challenge && !r.seats.some((s) => s.name.toLowerCase() === r.keeper.toLowerCase()) && r.seats.length + r.expected.length < r.size}
|
||||
<div class="keeper">
|
||||
<button type="button" class="quiet" title="A seat is held for {r.keeper}, who keeps this site, and their phone rings" onclick={() => room?.callKeeper()}>Challenge {r.keeper}, the keeper</button>
|
||||
<p class="muted small">{r.keeper} keeps this site and answers every call, sometimes within the minute, sometimes after a night's sleep.{#if keeperHour} It is {keeperHour} where {r.keeper} lives.{/if} The seat is held either way.</p>
|
||||
</div>
|
||||
{:else if r.challenge}
|
||||
<p class="muted small">{r.keeper} has been called to the table by {room.speaker(r.challenge.by)}.{#if keeperHour} It is {keeperHour} there.{/if} A seat is held: if they can come now they will take it; if they are asleep or away they will leave a word below when they can. Meanwhile the table is yours: seat a bot, invite a friend, or open another duel in a new tab and play on. This table keeps its place in your hall until you come back.</p>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if r.error}<p class="notice">{r.error}</p>{/if}
|
||||
<div class="lobby-talk"><TableTalk duel={room} /></div>
|
||||
@@ -274,6 +366,29 @@
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.6rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.keeper {
|
||||
margin-top: 0.8rem;
|
||||
max-width: 38em;
|
||||
}
|
||||
|
||||
.small {
|
||||
font-size: 0.85rem;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
.phrase {
|
||||
letter-spacing: 0.06em;
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.12em;
|
||||
|
||||
Reference in New Issue
Block a user