Rematches, seats that travel by phrase, the keeper's bell with the hour where they live, the host's leave for the gallery to talk, and seats doubted before they are dropped
Ported from Wiz-War, where each earned its keep. All on the ledger: galleryTalk, challenge and rematch lines, gallery chat signed with a name, held seats on a rematch table's room line. Twenty-two protocol checks against a scratch server and a replay after restart. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
65d62f2ab3
commit
6dce3b82f0
+22
-3
@@ -21,9 +21,26 @@ README.
|
||||
read-only view showing only what every seat could see, counted for the
|
||||
table (*3 in the gallery*). While the table is laid, a watcher is offered
|
||||
a seat first; the gallery is the afterthought.
|
||||
- **Table talk** is the chat panel, seated players only. Each line is a
|
||||
ledger line, so it replays with the game, and the hall counts lines said
|
||||
while you were away.
|
||||
- **Table talk** is the chat panel, seated players only unless the host
|
||||
*lets the Peanut Gallery talk*; then a watcher signs a name no seat holds
|
||||
and their lines read *Name (gallery)*. Each line is a ledger line, so it
|
||||
replays with the game, and the hall counts lines said while you were away.
|
||||
- **A rematch**: a finished table may call for one. A new table of the same
|
||||
size opens with the same bots, seats held for the same people, and the
|
||||
caller as host; a second call finds it already open. Held seats refuse
|
||||
strangers until their players are back.
|
||||
- **The keeper's bell**: while a table is laid, a seated player may
|
||||
*Challenge the keeper*. A seat is held under the keeper's name, the call
|
||||
goes on the ledger, and the keeper's phone rings through Sentry. The note
|
||||
beside the button says what hour it is where the keeper lives, so a
|
||||
caller knows whether to wait ten minutes or come back tomorrow.
|
||||
- **A seat travels by phrase**: *Transfer seat* in the masthead mints four
|
||||
words good for ten minutes and one use; the hall's *bring a seat from
|
||||
another device* claims them. The browser that claims holds the seat too.
|
||||
- **Held seats are doubted before they are dropped**: the hall forgets a
|
||||
seat only on the second refusal in a row from the server. A restart, a
|
||||
stale answer or the wrong backend answer with ignorance, and ignorance
|
||||
is not deletion.
|
||||
- **The ledger** is the append-only JSONL file that *is* the game: room,
|
||||
seats, start (seed and rules revision), each seat's submitted input, the
|
||||
turn that resolves them, talk, over. A room is its ledger replayed
|
||||
@@ -102,6 +119,8 @@ Every game gets the same tools, installed by `deploy.sh` on every deploy:
|
||||
before its reply goes out.
|
||||
- Sentry: one project per game; `sentry-slack-alert.sh` routes every issue
|
||||
to `#<slug>-notifications`, run once by the keeper with their own token.
|
||||
- `KEEPER` and `KEEPER_TZ` in the service unit name the keeper a table may
|
||||
call and where they sleep.
|
||||
|
||||
## The look
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
The shared infrastructure of Wiz-War and Waving Hands, extracted so the next
|
||||
game starts with all of it in place: the hall, room codes and tables, the
|
||||
Peanut Gallery, table talk, append-only ledgers replayed through a
|
||||
Peanut Gallery (and the host's leave for it to talk), table talk, rematches,
|
||||
seats that travel between devices by phrase, the keeper's bell, append-only
|
||||
ledgers replayed through a
|
||||
deterministic engine, the rules revision, the reports desk, the deploy
|
||||
script with its determinism gate, nightly rollup and backup, Sentry with
|
||||
Slack alerts, and the visitors, pulse and reports skills for Claude.
|
||||
|
||||
@@ -7,6 +7,8 @@ Type=simple
|
||||
User=__SLUG__
|
||||
WorkingDirectory=/opt/__SLUG__/app/server
|
||||
Environment=PORT=__PORT__
|
||||
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=DATA_DIR=/var/lib/__SLUG__/rooms
|
||||
|
||||
@@ -32,6 +32,9 @@ const PORT = Number(process.env.PORT ?? '__PORT__');
|
||||
const HOST = process.env.HOST ?? '127.0.0.1';
|
||||
const DATA_DIR = process.env.DATA_DIR ?? '../data/rooms';
|
||||
const PUBLIC_URL = (process.env.PUBLIC_URL ?? 'https://__DOMAIN__').replace(/\/$/, '');
|
||||
/** 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.
|
||||
@@ -43,7 +46,7 @@ process.on('unhandledRejection', (reason) => {
|
||||
Sentry.captureException(reason);
|
||||
});
|
||||
|
||||
const rooms = new Rooms(game, new Store(DATA_DIR));
|
||||
const rooms = new Rooms(game, 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));
|
||||
/** Opening rooms and taking seats are open to anyone; a script gets a few dozen an hour, not thousands. */
|
||||
@@ -52,6 +55,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(() => {
|
||||
@@ -59,6 +68,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();
|
||||
|
||||
@@ -108,7 +120,7 @@ async function readBody(req: IncomingMessage): Promise<Record<string, unknown>>
|
||||
}
|
||||
}
|
||||
|
||||
/** A report or an answer is worth waking the keeper for; the desk's own replies are not. */
|
||||
/** A report, an answer, or a call to the table is worth waking the keeper for; the desk's own replies are not. */
|
||||
function ringBell(title: string, detail: { fingerprint: string[]; tags: Record<string, string>; extra: Record<string, unknown> }): void {
|
||||
Sentry.captureMessage(title, { level: 'error', ...detail });
|
||||
}
|
||||
@@ -166,6 +178,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') {
|
||||
@@ -207,10 +224,39 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
|
||||
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
|
||||
}
|
||||
if (action === 'say') {
|
||||
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);
|
||||
rooms.say(room, token, body.text);
|
||||
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
|
||||
}
|
||||
if (action === 'gallery') {
|
||||
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 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 game — ${link}`, {
|
||||
fingerprint: ['challenge', room.id],
|
||||
tags: { room: room.id, challenger: seat.name },
|
||||
extra: { link, players: room.seats.map((s) => s.name).join(', ') }
|
||||
});
|
||||
return send(res, 200, rooms.view(room, seat.id));
|
||||
}
|
||||
if (action === 'rematch') {
|
||||
const next = rooms.rematch(room, token);
|
||||
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, token));
|
||||
}
|
||||
if (action === 'report') {
|
||||
if (!desk.allow(clientOf(req))) throw new RoomError(DESK_BUSY, 429);
|
||||
const seat = token ? rooms.seatOf(room, token) : null;
|
||||
|
||||
@@ -31,6 +31,16 @@ export interface Room<State> {
|
||||
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: SeatId; at: number } | null;
|
||||
/** Names with a seat held for them: the players 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: SeatId } | null;
|
||||
/** The table this one is the rematch of. */
|
||||
rematchOf: string | null;
|
||||
state: State | null;
|
||||
/** Inputs received for the round in progress, by seat. */
|
||||
pending: Record<SeatId, unknown>;
|
||||
@@ -64,6 +74,16 @@ 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;
|
||||
|
||||
export function normalizeCode(raw: string): string {
|
||||
return raw.trim().toUpperCase();
|
||||
}
|
||||
@@ -85,7 +105,9 @@ export class Rooms<State, Input> {
|
||||
|
||||
constructor(
|
||||
private game: GameSpec<State, Input>,
|
||||
private store: Store
|
||||
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 = this.replay(id, store.read(id));
|
||||
@@ -141,11 +163,21 @@ export class Rooms<State, Input> {
|
||||
if (!Number.isInteger(size) || size < minSeats || size > this.maxSeats) {
|
||||
throw new RoomError(`A table seats ${minSeats} to ${this.maxSeats} players.`);
|
||||
}
|
||||
return this.open(name, size);
|
||||
}
|
||||
|
||||
/** A table with nobody seated yet. */
|
||||
private blank(id: string, size: number, createdAt: number, rematchOf: string | null, expected: string[], seq = 0): Room<State> {
|
||||
return { id, size, createdAt, seats: [], galleryTalk: false, challenge: null, expected, rematch: null, rematchOf, state: null, pending: {}, chat: [], seq, updatedAt: createdAt };
|
||||
}
|
||||
|
||||
/** Open a table with its first seat taken; a rematch names the table it follows and holds seats for its players. */
|
||||
private open(name: string, size: number, follows?: { rematchOf: string; expected: string[] }): { room: Room<State>; seat: Seat; token: string } {
|
||||
let id = newCode();
|
||||
while (this.rooms.has(id) || this.store.exists(id)) id = newCode();
|
||||
const room: Room<State> = { id, size, createdAt: Date.now(), seats: [], state: null, pending: {}, chat: [], seq: 0, updatedAt: Date.now() };
|
||||
const room = this.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) };
|
||||
}
|
||||
|
||||
@@ -154,6 +186,99 @@ export class Rooms<State, Input> {
|
||||
return this.sit(room, name, false);
|
||||
}
|
||||
|
||||
/** Names with a seat held who have not yet sat. */
|
||||
private awaited(room: Room<State>): 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<State>, token: string | undefined, on: boolean): void {
|
||||
const seat = this.seatOf(room, token);
|
||||
if (seat.id !== room.seats[0]?.id) throw new RoomError('Only the player 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<State>, 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 player at the table has that name.', 409);
|
||||
this.commit(room, { t: 'chat', at: Date.now(), id: SPECTATOR, name, text: this.cleanTalk(raw) });
|
||||
}
|
||||
|
||||
/**
|
||||
* A seated player 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 game begins. The caller rings the keeper's bell.
|
||||
*/
|
||||
callKeeper(room: Room<State>, token: string | undefined, keeper: string): void {
|
||||
const seat = this.seatOf(room, token);
|
||||
if (room.state) throw new RoomError('The game 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<State>, token: string | undefined): { roomId: string; seat: Seat | null; token: string | null; created: boolean } {
|
||||
const caller = this.seatOf(room, token);
|
||||
if (!room.state || !this.game.over(room.state)) throw new RoomError('The game 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: SeatId; 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<State>, 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: SeatId; 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 once enough are seated, however full the table. */
|
||||
begin(room: Room<State>, token: string | undefined): void {
|
||||
const seat = this.seatOf(room, token);
|
||||
@@ -178,6 +303,10 @@ export class Rooms<State, Input> {
|
||||
private sit(room: Room<State>, rawName: string, bot: boolean): { seat: Seat; token: string } {
|
||||
if (room.seats.length >= room.size) throw new RoomError('Every seat at this table 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 players expected at this table.', 409);
|
||||
}
|
||||
if (room.seats.some((s) => s.name.toLowerCase() === name.toLowerCase())) throw new RoomError('Another player here already has that name.', 409);
|
||||
const id = this.game.seatIds.find((s) => !room.seats.some((taken) => taken.id === s))!;
|
||||
const token = bot ? '' : newId(18);
|
||||
@@ -227,13 +356,17 @@ export class Rooms<State, Input> {
|
||||
/** Table talk, from a seat to everyone at the table and in the gallery. */
|
||||
say(room: Room<State>, token: string | undefined, raw: unknown): void {
|
||||
const seat = this.seatOf(room, token);
|
||||
this.commit(room, { t: 'chat', at: Date.now(), id: seat.id, text: this.cleanTalk(raw) });
|
||||
}
|
||||
|
||||
private 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.');
|
||||
this.commit(room, { t: 'chat', at: Date.now(), id: seat.id, text });
|
||||
return text;
|
||||
}
|
||||
|
||||
/** Seats whose input the next resolution needs and has not yet received. */
|
||||
@@ -258,7 +391,14 @@ export class Rooms<State, Input> {
|
||||
over: room.state ? this.game.over(room.state) : null,
|
||||
state: room.state ? this.game.view(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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -307,6 +447,16 @@ export class Rooms<State, Input> {
|
||||
case 'seat':
|
||||
room.seats.push({ id: line.id, name: line.name, tokenHash: line.tokenHash ?? (line.token ? hashToken(line.token) : ''), bot: line.bot });
|
||||
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 'input':
|
||||
room.pending[line.id] = this.game.cleanInput(line.input);
|
||||
break;
|
||||
@@ -322,7 +472,7 @@ export class Rooms<State, Input> {
|
||||
room.pending = {};
|
||||
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;
|
||||
}
|
||||
@@ -331,7 +481,7 @@ export class Rooms<State, Input> {
|
||||
private replay(id: string, lines: LedgerLine[]): Room<State> | null {
|
||||
const first = lines[0];
|
||||
if (!first || first.t !== 'room') return null;
|
||||
const room: Room<State> = { id, size: first.seats, createdAt: first.createdAt, seats: [], state: null, pending: {}, chat: [], seq: lines.length, updatedAt: first.createdAt };
|
||||
const room = this.blank(id, first.seats, first.createdAt, first.rematchOf ?? null, first.expected ?? [], lines.length);
|
||||
for (const line of lines) {
|
||||
this.apply(room, line);
|
||||
if (line.t === 'turn' || line.t === 'input') room.updatedAt = line.at;
|
||||
|
||||
@@ -7,7 +7,8 @@ import { join } from 'node:path';
|
||||
import type { SeatId } from '../../src/lib/game/spec';
|
||||
|
||||
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: SeatId; name: string; tokenHash?: string; token?: string; bot: boolean }
|
||||
/** One seat's move for the round 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<SeatId, unknown> }
|
||||
/** Written after the turn that ends the game, for anything that reads ledgers without the engine. */
|
||||
| { t: 'over'; at: number; winner: SeatId | null; reason: string }
|
||||
/** A line of table talk from a seated player. */
|
||||
| { t: 'chat'; at: number; id: SeatId; text: string };
|
||||
/** A line of table talk: from a seat, or, with `name`, from the Peanut Gallery (id SPECTATOR). */
|
||||
| { t: 'chat'; at: number; id: SeatId; text: string; name?: string }
|
||||
/** The host let the gallery talk, or hushed it. */
|
||||
| { t: 'galleryTalk'; at: number; on: boolean; by: SeatId }
|
||||
/** A seat is held for the keeper of the site, called to the table. */
|
||||
| { t: 'challenge'; at: number; by: SeatId; keeper: string }
|
||||
/** A finished table called for a rematch: where it went, and who called. */
|
||||
| { t: 'rematch'; at: number; to: string; by: SeatId };
|
||||
|
||||
export class Store {
|
||||
constructor(private dir: string) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// bot, open a table, join by code, the ledger of your tables, and the
|
||||
// about panel with the rules, the guide and a way to reach the keeper.
|
||||
import { goto } from '$app/navigation';
|
||||
import { allSeats, forgetSeat, type Report } from '$lib/net/client';
|
||||
import { allSeats, doubtSeat, forgetSeat, rememberSeat, ServerError, trustSeat, type Report } from '$lib/net/client';
|
||||
import { api, NAME_MAX, playerName, rememberName, Room } from '$lib/net/room.svelte';
|
||||
import { unreadTalk } from '$lib/net/talk';
|
||||
import type { RoomView } from '$lib/net/view';
|
||||
@@ -24,17 +24,44 @@
|
||||
|
||||
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) => (games[held.roomId] = v))
|
||||
.catch(() => (games[held.roomId] = null));
|
||||
.then((v) => {
|
||||
trustSeat(held.roomId);
|
||||
games[held.roomId] = v;
|
||||
})
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
// The desk is asked once per visit for every seat this browser holds.
|
||||
$effect(() => {
|
||||
const held = seats;
|
||||
@@ -134,6 +161,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>
|
||||
|
||||
|
||||
@@ -7,8 +7,29 @@
|
||||
const v = $derived(room.view);
|
||||
const hostName = $derived(v.seats.find((s) => s.id === v.host)?.name ?? 'the host');
|
||||
const seatFree = $derived(v.seats.length < v.size);
|
||||
/** A seat is free for a newcomer once the held ones are counted. */
|
||||
const seatOpen = $derived(v.seats.length + v.expected.length < v.size);
|
||||
const keeperSeated = $derived(v.seats.some((s) => s.name.toLowerCase() === v.keeper.toLowerCase()));
|
||||
let copied = $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;
|
||||
try {
|
||||
const hour = Number(new Intl.DateTimeFormat('en-US', { hour: 'numeric', hour12: false, timeZone: v.keeperZone }).format(new Date())) % 24;
|
||||
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 copyLink() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(link);
|
||||
@@ -42,8 +63,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">{v.size - v.seats.length} of {v.size} seats empty{#if v.audience > 0}; {v.audience} in the gallery{/if}</li>
|
||||
{#each v.expected as name (name)}
|
||||
<li>
|
||||
<span class="seat-name">{name}</span>
|
||||
<span class="muted">{v.challenge && name.toLowerCase() === v.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">{v.size - v.seats.length - v.expected.length} of {v.size} seats empty{#if v.audience > 0}; {v.audience} in the gallery{v.galleryTalk ? ', allowed to talk' : ''}{/if}</li>
|
||||
</ul>
|
||||
{#if v.rematchOf}
|
||||
<p class="muted">A rematch of table {v.rematchOf}: the same company, seats held for those not yet back.</p>
|
||||
{/if}
|
||||
{#if !room.spectating}
|
||||
<div class="ways">
|
||||
{#if seatFree && room.isHost}
|
||||
@@ -56,6 +86,20 @@
|
||||
<span class="muted">{hostName} will begin when the table is ready.</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if room.isHost}
|
||||
<label class="switch muted">
|
||||
<input type="checkbox" checked={v.galleryTalk} onchange={(e) => room.setGalleryTalk(e.currentTarget.checked)} />
|
||||
Let the Peanut Gallery talk at the table
|
||||
</label>
|
||||
{/if}
|
||||
{#if v.keeper && !v.challenge && !keeperSeated && seatOpen}
|
||||
<div class="keeper">
|
||||
<button type="button" class="quiet" title="A seat is held for {v.keeper}, who keeps this site, and their phone rings" onclick={() => room.callKeeper()}>Challenge {v.keeper}, the keeper</button>
|
||||
<p class="muted small">{v.keeper} keeps this site and answers every call, sometimes within the minute, sometimes after a night's sleep.{#if keeperHour} It is {keeperHour} where {v.keeper} lives.{/if} The seat is held either way.</p>
|
||||
</div>
|
||||
{:else if v.challenge}
|
||||
<p class="muted small">{v.keeper} has been called to the table by {room.nameOf(v.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 game in a new tab and play on. This table keeps its place in your hall until you come back.</p>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if room.error}<p class="warning">{room.error}</p>{/if}
|
||||
<div class="lobby-talk"><TableTalk {room} /></div>
|
||||
@@ -143,4 +187,22 @@
|
||||
max-width: 28em;
|
||||
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;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import type { Room } from '$lib/net/room.svelte';
|
||||
import { NAME_MAX, playerName, rememberName, type Room } from '$lib/net/room.svelte';
|
||||
|
||||
let { room }: { room: Room } = $props();
|
||||
|
||||
const lines = $derived(room.view.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);
|
||||
|
||||
@@ -25,6 +27,17 @@
|
||||
if (await room.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 room.sayFromGallery(name, text)) draft = '';
|
||||
sending = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="talk" aria-label="Table talk">
|
||||
@@ -34,11 +47,17 @@
|
||||
<li class="muted">Nobody has said a word.</li>
|
||||
{/if}
|
||||
{#each lines as line, i (line.at + ':' + i)}
|
||||
<li class:you={line.id === room.me}><em>{line.id === room.me ? 'You' : room.nameOf(line.id)}</em> {line.text}</li>
|
||||
<li class:you={!line.name && line.id === room.me}><em>{line.name ? `${line.name} (gallery)` : line.id === room.me ? 'You' : room.nameOf(line.id)}</em> {line.text}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if room.spectating}
|
||||
<p class="muted small">The gallery listens; only seated players speak.</p>
|
||||
{#if room.spectating && room.view.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 room.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" />
|
||||
@@ -102,6 +121,10 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
input.name {
|
||||
flex: 0 1 8rem;
|
||||
}
|
||||
|
||||
.small {
|
||||
font-size: 0.85rem;
|
||||
margin-top: 0.4rem;
|
||||
|
||||
@@ -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 = '__SLUG__: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 }));
|
||||
@@ -94,6 +142,14 @@ export function makeApi<State, Input>() {
|
||||
watch: (roomId: string) => call<View>('GET', `/api/rooms/${roomId}`),
|
||||
turn: (roomId: string, token: string, input: Input) => call<View>('POST', `/api/rooms/${roomId}/turn`, { token, input }),
|
||||
say: (roomId: string, token: string, text: string) => call<View>('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<View>('POST', `/api/rooms/${roomId}/say`, { name, text }),
|
||||
setGalleryTalk: (roomId: string, token: string, on: boolean) => call<View>('POST', `/api/rooms/${roomId}/gallery`, { token, on }),
|
||||
challenge: (roomId: string, token: string) => call<View>('POST', `/api/rooms/${roomId}/challenge`, { token }),
|
||||
rematch: (roomId: string, token: string) =>
|
||||
call<{ roomId: string; seat: SeatId | 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: SeatId; 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) => {
|
||||
|
||||
@@ -114,6 +114,34 @@ export class Room {
|
||||
return this.act(() => api.say(this.roomId, this.token, text), 'The table did not hear you.');
|
||||
}
|
||||
|
||||
/** A word from the gallery, signed; only when the host has let the gallery talk. */
|
||||
sayFromGallery(name: string, text: string): Promise<boolean> {
|
||||
if (!this.spectating) return Promise.resolve(false);
|
||||
return this.act(() => api.sayFromGallery(this.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.act(() => api.setGalleryTalk(this.roomId, this.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.act(() => api.challenge(this.roomId, this.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 next = await api.rematch(this.roomId, this.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 }> {
|
||||
return api.transfer(this.roomId, this.token);
|
||||
}
|
||||
|
||||
async submit(input: Input): Promise<boolean> {
|
||||
this.sending = true;
|
||||
try {
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
|
||||
import type { Outcome, SeatId } from '../game/spec';
|
||||
|
||||
/** One line of table talk. Only seated players 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: SeatId;
|
||||
text: string;
|
||||
at: number;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface RoomView<State> {
|
||||
@@ -29,4 +30,17 @@ export interface RoomView<State> {
|
||||
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: SeatId; at: number } | null;
|
||||
/** Names with a seat held who have not yet sat: the last table's players, or the keeper. */
|
||||
expected: string[];
|
||||
/** A finished table's rematch: where it went, and who called. */
|
||||
rematch: { to: string; by: SeatId } | 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;
|
||||
}
|
||||
|
||||
@@ -40,7 +40,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 game has begun. Anyone who opens it without a seat takes a place in the Peanut Gallery, shown only what every player at the table could see. <em>Table talk</em> is for the players seated; a line goes to everyone at the table and everyone in the gallery, and is kept with the game.</p>
|
||||
<p>A room's link is an invitation to sit while the table is laid and to watch once the game has begun. Anyone who opens it without a seat takes a place in the Peanut Gallery, shown only what every player at the table could see. <em>Table talk</em> is for the players seated, unless the host lets the gallery talk; then a watcher signs a name and their lines are marked as the gallery's. A line goes to everyone at the table and everyone in the gallery, and is kept with the game.</p>
|
||||
<p>A seat can follow you to another device: <em>Transfer seat</em> in the masthead gives four words, good for ten minutes, and the hall on the other device claims them. When a game ends, anyone at the table may call for a rematch: a new table with the same company, seats held until everyone is back. And while a table is laid, you may challenge the keeper of this site to a seat; the note beside the button says what hour it is where they live.</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 Lobby from '$lib/components/Lobby.svelte';
|
||||
@@ -15,6 +16,9 @@
|
||||
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);
|
||||
|
||||
const link = $derived(typeof location === 'undefined' ? '' : `${location.origin}/join/${roomId}`);
|
||||
const watching = $derived(room?.spectating ?? false);
|
||||
@@ -64,6 +68,29 @@
|
||||
// The link is on screen to copy by hand.
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -87,7 +114,10 @@
|
||||
<span class="muted">{watching ? 'watching · room' : 'invite friends · room'}</span> <b>{roomId}</b>{copied ? ' · link copied' : ''}
|
||||
</button>
|
||||
{#if room.view.audience > 0}
|
||||
<span class="audience" title="the Peanut Gallery">{room.view.audience} in the gallery</span>
|
||||
<span class="audience" title="the Peanut Gallery">{room.view.audience} in the gallery{room.view.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>
|
||||
@@ -107,6 +137,22 @@
|
||||
<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?.view.over && !watching}
|
||||
<p class="notice muted">
|
||||
{#if room.view.rematch}
|
||||
{room.nameOf(room.view.rematch.by)} called for a rematch at table {room.view.rematch.to}.
|
||||
<a href={`/join/${room.view.rematch.to}`}>Take your seat</a>
|
||||
{:else}
|
||||
<button type="button" class="quiet" disabled={rematching} onclick={rematch}>Rematch 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="seat">
|
||||
<p class="eyebrow">room {roomId}</p>
|
||||
@@ -193,6 +239,11 @@
|
||||
color: var(--bone-dim);
|
||||
}
|
||||
|
||||
.phrase {
|
||||
letter-spacing: 0.06em;
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.seat {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
|
||||
Reference in New Issue
Block a user