The Peanut Gallery and table talk

A room's link without a seat is now a place in the gallery: a tokenless
view that withholds any gesture hidden from anyone at the table, counted
for the table and the lobby. Seated wizards can talk; each line is a
ledger line, shown in the rail and in the lobby, and the hall counts
what was said while you were away. The hall's pitch no longer says two
wizards.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
This commit is contained in:
Eric Wagoner
2026-09-23 09:50:06 -04:00
co-authored by Claude Fable 5.1
parent 75c2dc9241
commit 8e7376d59a
19 changed files with 488 additions and 80 deletions
@@ -25,7 +25,8 @@ spills into the next UTC day. Run yesterday too when the hour is early.
NEW means the name's first room ever is today. NEW means the name's first room ever is today.
- **rooms today**: one line each: time, code, humans vs bots, empty - **rooms today**: one line each: time, code, humans vs bots, empty
seats, state (lobby only / started, no turns / in play), turn count, seats, state (lobby only / started, no turns / in play), turn count,
span, and the last turn's time. span, the last turn's time, and how many lines of table talk were said.
Talk is a sign people came together; a bot game has none.
## Read it ## Read it
+3 -1
View File
@@ -22,7 +22,9 @@ people has a four-letter room code; `/join/CODE` is both the invite link and the
played. Turns are taken whenever each player has a moment, and the page refreshes itself when played. Turns are taken whenever each player has a moment, and the page refreshes itself when
the other wizard moves. An empty seat can be given to the bot. Each turn, choose a gesture per the other wizard moves. An empty seat can be given to the bot. Each turn, choose a gesture per
hand, pick which completed spell (if any) each hand casts and at whom, and reveal. The rules hand, pick which completed spell (if any) each hand casts and at whom, and reveal. The rules
are at `/rules`, and a walk through the page with screenshots at `/guide`. are at `/rules`, and a walk through the page with screenshots at `/guide`. A room's link opened
without a seat is the Peanut Gallery: a read-only view showing only what every wizard could see.
Seated wizards can talk to the table; the talk is a ledger line like any other.
Around the two hands: Around the two hands:
+7 -4
View File
@@ -81,7 +81,7 @@ for f in glob.glob("/var/lib/waving-hands/rooms/*.jsonl"):
except Exception: except Exception:
continue continue
created = datetime.datetime.fromtimestamp(meta["createdAt"] / 1000, datetime.timezone.utc) created = datetime.datetime.fromtimestamp(meta["createdAt"] / 1000, datetime.timezone.utc)
humans, bots_seated, started, turns, last, over = [], [], False, 0, created, None humans, bots_seated, started, turns, last, over, talk = [], [], False, 0, created, None, 0
names = {} names = {}
for x in lines[1:]: for x in lines[1:]:
if x["t"] == "seat": if x["t"] == "seat":
@@ -92,12 +92,14 @@ for f in glob.glob("/var/lib/waving-hands/rooms/*.jsonl"):
elif x["t"] == "turn": elif x["t"] == "turn":
turns += 1 turns += 1
last = datetime.datetime.fromtimestamp(x["at"] / 1000, datetime.timezone.utc) last = datetime.datetime.fromtimestamp(x["at"] / 1000, datetime.timezone.utc)
elif x["t"] == "chat":
talk += 1
elif x["t"] == "over": elif x["t"] == "over":
over = "a draw" if x["winner"] is None else "%s won" % names.get(x["winner"], x["winner"]) over = "a draw" if x["winner"] is None else "%s won" % names.get(x["winner"], x["winner"])
for h in humans: for h in humans:
if h not in first_seen or created < first_seen[h]: if h not in first_seen or created < first_seen[h]:
first_seen[h] = created first_seen[h] = created
rooms.append(dict(id=meta["id"], size=meta["seats"], created=created, humans=humans, bots=bots_seated, started=started, turns=turns, last=last, over=over)) rooms.append(dict(id=meta["id"], size=meta["seats"], created=created, humans=humans, bots=bots_seated, started=started, turns=turns, last=last, over=over, talk=talk))
today = sorted([r for r in rooms if r["created"].strftime("%F") == day], key=lambda r: r["created"]) today = sorted([r for r in rooms if r["created"].strftime("%F") == day], key=lambda r: r["created"])
names = sorted({h for r in today for h in r["humans"]}) names = sorted({h for r in today for h in r["humans"]})
@@ -110,7 +112,8 @@ for r in today:
span = ("%d min" % round((r["last"] - r["created"]).total_seconds() / 60)) if r["turns"] else "" span = ("%d min" % round((r["last"] - r["created"]).total_seconds() / 60)) if r["turns"] else ""
seated = "+".join(r["humans"]) + ((" vs %d bot%s" % (len(r["bots"]), "" if len(r["bots"]) == 1 else "s")) if r["bots"] else "") seated = "+".join(r["humans"]) + ((" vs %d bot%s" % (len(r["bots"]), "" if len(r["bots"]) == 1 else "s")) if r["bots"] else "")
empty = r["size"] - len(r["humans"]) - len(r["bots"]) empty = r["size"] - len(r["humans"]) - len(r["bots"])
print(" %s %s %s%s %s %d turns %s last turn %s" % ( print(" %s %s %s%s %s %d turns %s last turn %s%s" % (
r["created"].strftime("%H:%M"), r["id"], seated, (" (%d seat%s empty)" % (empty, "" if empty == 1 else "s")) if empty else "", r["created"].strftime("%H:%M"), r["id"], seated, (" (%d seat%s empty)" % (empty, "" if empty == 1 else "s")) if empty else "",
state, r["turns"], span, r["last"].strftime("%H:%M") if r["turns"] else "never")) state, r["turns"], span, r["last"].strftime("%H:%M") if r["turns"] else "never",
(" %d line%s of talk" % (r["talk"], "" if r["talk"] == 1 else "s")) if r["talk"] else ""))
PY PY
+28 -11
View File
@@ -5,9 +5,11 @@
// POST /api/rooms/:id/join {name} take the next seat // POST /api/rooms/:id/join {name} take the next seat
// POST /api/rooms/:id/bot {token} seat a bot (any seated player may, before the duel begins) // POST /api/rooms/:id/bot {token} seat a bot (any seated player may, before the duel begins)
// POST /api/rooms/:id/begin {token} the host begins the duel with the wizards seated so far // POST /api/rooms/:id/begin {token} the host begins the duel with the wizards seated so far
// GET /api/rooms/:id?token= the view for that seat // 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/turn {token, input} this seat's move
// WS /ws?room=:id&token= {type:"update", seq} whenever the room changes // POST /api/rooms/:id/say {token, text} table talk, from a seat to the whole room
// 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
// //
// Caddy serves the static site and proxies /api and /ws here. // Caddy serves the static site and proxies /api and /ws here.
@@ -15,7 +17,7 @@ import * as Sentry from '@sentry/node';
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { WebSocketServer, WebSocket } from 'ws'; import { WebSocketServer, WebSocket } from 'ws';
import { RateLimit } from './ratelimit'; import { RateLimit } from './ratelimit';
import { RoomError, Rooms } from './rooms'; import { RoomError, Rooms, SPECTATOR } from './rooms';
import { Store } from './store'; import { Store } from './store';
const PORT = Number(process.env.PORT ?? 8788); const PORT = Number(process.env.PORT ?? 8788);
@@ -35,10 +37,14 @@ process.on('unhandledRejection', (reason) => {
const rooms = new Rooms(new Store(DATA_DIR)); const rooms = new Rooms(new Store(DATA_DIR));
/** Opening rooms and taking seats are open to anyone; a script gets a few dozen an hour, not thousands. */ /** Opening rooms and taking seats are open to anyone; a script gets a few dozen an hour, not thousands. */
const doors = new RateLimit(40, 60 * 60 * 1000); const doors = new RateLimit(40, 60 * 60 * 1000);
/** Table talk: a lively table says a few lines a minute, not hundreds. */
const voices = new RateLimit(240, 60 * 60 * 1000);
const MAX_AUDIENCE = 30;
const IDLE_ROOM_MS = 7 * 24 * 60 * 60 * 1000; const IDLE_ROOM_MS = 7 * 24 * 60 * 60 * 1000;
setInterval(() => { setInterval(() => {
const n = rooms.evictIdle(IDLE_ROOM_MS); const n = rooms.evictIdle(IDLE_ROOM_MS);
doors.prune(); doors.prune();
voices.prune();
if (n) console.log(`evicted ${n} idle room${n === 1 ? '' : 's'} from memory; ${rooms.loaded} loaded`); if (n) console.log(`evicted ${n} idle room${n === 1 ? '' : 's'} from memory; ${rooms.loaded} loaded`);
}, 60 * 60 * 1000).unref(); }, 60 * 60 * 1000).unref();
@@ -95,8 +101,9 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
const room = rooms.get(parts[2] ?? ''); const room = rooms.get(parts[2] ?? '');
const action = parts[3]; const action = parts[3];
if (!action && req.method === 'GET') { if (!action && req.method === 'GET') {
const seat = rooms.seatOf(room, url.searchParams.get('token') ?? undefined); const token = url.searchParams.get('token');
return send(res, 200, rooms.view(room, seat.id)); if (!token) return send(res, 200, rooms.view(room, SPECTATOR));
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
} }
if (req.method !== 'POST') throw new RoomError('Not here.', 404); if (req.method !== 'POST') throw new RoomError('Not here.', 404);
const body = await readBody(req); const body = await readBody(req);
@@ -122,6 +129,13 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
const seat = rooms.seatOf(room, token); const seat = rooms.seatOf(room, token);
return send(res, 200, rooms.view(room, seat.id)); return send(res, 200, rooms.view(room, seat.id));
} }
if (action === 'say') {
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);
const seat = rooms.seatOf(room, token);
return send(res, 200, rooms.view(room, seat.id));
}
throw new RoomError('Not here.', 404); throw new RoomError('Not here.', 404);
} }
@@ -139,17 +153,20 @@ server.on('upgrade', (req, socket, head) => {
const url = new URL(req.url ?? '/', 'http://localhost'); const url = new URL(req.url ?? '/', 'http://localhost');
if (url.pathname !== '/ws') return socket.destroy(); if (url.pathname !== '/ws') return socket.destroy();
let seatId: string; let seatId: string;
let roomId: string; let room: ReturnType<Rooms['get']>;
try { try {
roomId = url.searchParams.get('room') ?? ''; room = rooms.get(url.searchParams.get('room') ?? '');
seatId = rooms.seatOf(rooms.get(roomId), url.searchParams.get('token') ?? undefined).id; const token = url.searchParams.get('token');
seatId = token ? rooms.seatOf(room, token).id : SPECTATOR;
if (seatId === SPECTATOR && rooms.audience(room) >= MAX_AUDIENCE) throw new RoomError('The gallery is full.', 429);
} catch { } catch {
return socket.destroy(); return socket.destroy();
} }
wss.handleUpgrade(req, socket, head, (ws: WebSocket) => { wss.handleUpgrade(req, socket, head, (ws: WebSocket) => {
const unsubscribe = rooms.subscribe(roomId, (room) => { const onChange = (changed: ReturnType<Rooms['get']>) => {
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'update', seq: room.seq })); if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'update', seq: changed.seq }));
}); };
const unsubscribe = seatId === SPECTATOR ? rooms.watch(room, onChange) : rooms.subscribe(room.id, onChange);
ws.on('close', unsubscribe); ws.on('close', unsubscribe);
ws.send(JSON.stringify({ type: 'hello', seat: seatId })); ws.send(JSON.stringify({ type: 'hello', seat: seatId }));
}); });
+49 -5
View File
@@ -7,14 +7,18 @@ import { randomBytes } from 'node:crypto';
import { chooseBotTurn } from '../../src/lib/game/bot'; import { chooseBotTurn } from '../../src/lib/game/bot';
import { resolveTurn } from '../../src/lib/game/resolve'; import { resolveTurn } from '../../src/lib/game/resolve';
import { GESTURES, type Gesture } from '../../src/lib/game/spells'; import { GESTURES, type Gesture } from '../../src/lib/game/spells';
import { createGame, inDuel, type GameState, type TurnInput, type WizardId } from '../../src/lib/game/state'; import { createGame, inDuel, SPECTATOR, type GameState, type TurnInput, type WizardId } from '../../src/lib/game/state';
import { viewFor, type RoomView } from '../../src/lib/game/view'; import { viewFor, type ChatLine, type RoomView } from '../../src/lib/game/view';
import type { LedgerLine, Store } from './store'; import type { LedgerLine, Store } from './store';
/** A dozen seats at most: a turn resolves only when every seat has moved, and the slowest sets the pace. */ /** A dozen seats at most: a turn resolves only when every seat has moved, and the slowest sets the pace. */
const SEAT_IDS: WizardId[] = 'ABCDEFGHJKLM'.split(''); const SEAT_IDS: WizardId[] = 'ABCDEFGHJKLM'.split('');
const BOT_NAMES = ['Aldric', 'Morwenna', 'Thessaly', 'Gandric', 'Ysolde', 'Ormund', 'Corwin', 'Isaura', 'Bellamy', 'Hesper', 'Lucan', 'Wynne']; const BOT_NAMES = ['Aldric', 'Morwenna', 'Thessaly', 'Gandric', 'Ysolde', 'Ormund', 'Corwin', 'Isaura', 'Bellamy', 'Hesper', 'Lucan', 'Wynne'];
export const MAX_SEATS = SEAT_IDS.length; export const MAX_SEATS = SEAT_IDS.length;
export { SPECTATOR };
const CHAT_MAX_LENGTH = 300;
/** Lines of talk kept and sent; the ledger keeps them all. */
const CHAT_KEEP = 200;
export interface Seat { export interface Seat {
id: WizardId; id: WizardId;
@@ -31,6 +35,7 @@ export interface Room {
state: GameState | null; state: GameState | null;
/** Inputs received for the turn in progress, by seat. */ /** Inputs received for the turn in progress, by seat. */
pending: Record<WizardId, TurnInput>; pending: Record<WizardId, TurnInput>;
chat: ChatLine[];
/** Bumped on every change a client might want to see. */ /** Bumped on every change a client might want to see. */
seq: number; seq: number;
updatedAt: number; updatedAt: number;
@@ -67,6 +72,8 @@ export function normalizeCode(raw: string): string {
export class Rooms { export class Rooms {
private rooms = new Map<string, Room>(); private rooms = new Map<string, Room>();
private listeners = new Map<string, Set<(room: Room) => void>>(); private listeners = new Map<string, Set<(room: Room) => void>>();
/** Sockets watching from the gallery, by room. */
private gallery = new Map<string, number>();
constructor(private store: Store) { constructor(private store: Store) {
for (const id of store.roomIds()) { for (const id of store.roomIds()) {
@@ -117,7 +124,7 @@ export class Rooms {
if (!Number.isInteger(size) || size < 2 || size > MAX_SEATS) throw new RoomError(`A table seats 2 to ${MAX_SEATS} wizards.`); if (!Number.isInteger(size) || size < 2 || size > MAX_SEATS) throw new RoomError(`A table seats 2 to ${MAX_SEATS} wizards.`);
let id = newCode(); let id = newCode();
while (this.rooms.has(id)) id = newCode(); while (this.rooms.has(id)) id = newCode();
const room: Room = { id, size, createdAt: Date.now(), seats: [], state: null, pending: {}, seq: 0, updatedAt: Date.now() }; const room: Room = { id, size, createdAt: Date.now(), seats: [], state: null, pending: {}, chat: [], seq: 0, updatedAt: Date.now() };
this.rooms.set(id, room); 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 });
const seat = this.sit(room, name, false); const seat = this.sit(room, name, false);
@@ -185,6 +192,19 @@ export class Rooms {
if (room.state?.over) this.commit(room, { t: 'over', at: Date.now(), winner: room.state.over.winner, reason: room.state.over.reason }); if (room.state?.over) this.commit(room, { t: 'over', at: Date.now(), winner: room.state.over.winner, reason: room.state.over.reason });
} }
/** 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 });
}
/** The view for a seat, or for the gallery when the seat is SPECTATOR. */
view(room: Room, seatId: WizardId): RoomView { view(room: Room, seatId: WizardId): RoomView {
return { return {
roomId: room.id, roomId: room.id,
@@ -194,7 +214,9 @@ export class Rooms {
host: room.seats[0]?.id ?? seatId, host: room.seats[0]?.id ?? seatId,
seats: room.seats.map((s) => ({ id: s.id, name: s.name, bot: s.bot })), seats: room.seats.map((s) => ({ id: s.id, name: s.name, bot: s.bot })),
waitingOn: waitingOn(room), waitingOn: waitingOn(room),
state: room.state ? viewFor(room.state, seatId) : null state: room.state ? viewFor(room.state, seatId) : null,
chat: room.chat,
audience: this.gallery.get(room.id) ?? 0
}; };
} }
@@ -204,6 +226,24 @@ export class Rooms {
return () => this.listeners.get(roomId)?.delete(fn); return () => this.listeners.get(roomId)?.delete(fn);
} }
/** Subscribe from the gallery; the table is told when its audience changes. */
watch(room: Room, fn: (room: Room) => void): () => void {
const unsubscribe = this.subscribe(room.id, fn);
this.gallery.set(room.id, (this.gallery.get(room.id) ?? 0) + 1);
room.seq += 1;
this.notify(room);
return () => {
unsubscribe();
this.gallery.set(room.id, Math.max(0, (this.gallery.get(room.id) ?? 1) - 1));
room.seq += 1;
this.notify(room);
};
}
audience(room: Room): number {
return this.gallery.get(room.id) ?? 0;
}
/** Write to the ledger, then apply; the file is the truth a restart returns to. */ /** Write to the ledger, then apply; the file is the truth a restart returns to. */
private commit(room: Room, line: LedgerLine): void { private commit(room: Room, line: LedgerLine): void {
this.store.append(room.id, line); this.store.append(room.id, line);
@@ -248,13 +288,17 @@ function apply(room: Room, line: LedgerLine): void {
break; break;
case 'over': case 'over':
break; break;
case 'chat':
room.chat.push({ id: line.id, text: line.text, at: line.at });
if (room.chat.length > CHAT_KEEP) room.chat.splice(0, room.chat.length - CHAT_KEEP);
break;
} }
} }
function replay(id: string, lines: LedgerLine[]): Room | null { function replay(id: string, lines: LedgerLine[]): Room | null {
const first = lines[0]; const first = lines[0];
if (!first || first.t !== 'room') return null; if (!first || first.t !== 'room') return null;
const room: Room = { id, size: first.seats, createdAt: first.createdAt, seats: [], state: null, pending: {}, seq: lines.length, updatedAt: first.createdAt }; const room: Room = { id, size: first.seats, createdAt: first.createdAt, seats: [], state: null, pending: {}, chat: [], seq: lines.length, updatedAt: first.createdAt };
for (const line of lines) { for (const line of lines) {
apply(room, line); apply(room, line);
if (line.t === 'turn') room.updatedAt = line.at; if (line.t === 'turn') room.updatedAt = line.at;
+3 -1
View File
@@ -12,7 +12,9 @@ export type LedgerLine =
| { t: 'start'; seed: number } | { t: 'start'; seed: number }
| { t: 'turn'; at: number; inputs: Record<WizardId, TurnInput> } | { t: 'turn'; at: number; inputs: Record<WizardId, TurnInput> }
/** Written after the turn that ends the duel, for anything that reads ledgers without the engine. */ /** 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 }; | { 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 };
export class Store { export class Store {
constructor(private dir: string) { constructor(private dir: string) {
+12 -3
View File
@@ -4,6 +4,7 @@
import Ledger from './Ledger.svelte'; import Ledger from './Ledger.svelte';
import MobileBar from './MobileBar.svelte'; import MobileBar from './MobileBar.svelte';
import SpellSheet from './SpellSheet.svelte'; import SpellSheet from './SpellSheet.svelte';
import TableTalk from './TableTalk.svelte';
import TurnSummary from './TurnSummary.svelte'; import TurnSummary from './TurnSummary.svelte';
import WizardStatus from './WizardStatus.svelte'; import WizardStatus from './WizardStatus.svelte';
import { castKey, listText, tokensText, type Duel } from '$lib/game/duel.svelte'; import { castKey, listText, tokensText, type Duel } from '$lib/game/duel.svelte';
@@ -24,7 +25,7 @@
{#if outcome} {#if outcome}
<div class="verdict" id="verdict" class:won> <div class="verdict" id="verdict" class:won>
<h2>{won ? 'You win.' : outcome.winner === null ? 'A draw.' : 'You lose.'}</h2> <h2>{duel.spectating ? (outcome.winner === null ? 'A draw.' : `${duel.state.wizards[outcome.winner].name} wins.`) : won ? 'You win.' : outcome.winner === null ? 'A draw.' : 'You lose.'}</h2>
<p>{outcome.reason}</p> <p>{outcome.reason}</p>
{#if duel.remote} {#if duel.remote}
<a class="reveal again" href="/">Back to the hall</a> <a class="reveal again" href="/">Back to the hall</a>
@@ -33,7 +34,12 @@
{/if} {/if}
</div> </div>
{:else} {:else}
{#if duel.out} {#if duel.spectating}
<section class="move out" aria-labelledby="move-heading">
<h2 id="move-heading">You watch from the Peanut Gallery</h2>
<p class="threats muted">{duel.awaitingText ? `${duel.awaitingText} ${duel.awaiting.length === 1 ? 'is' : 'are'} still writing turn ${duel.turnNumber}.` : `Turn ${duel.turnNumber} is being written.`} The gallery sees only what every wizard at the table can see.</p>
</section>
{:else if duel.out}
<section class="move out" aria-labelledby="move-heading"> <section class="move out" aria-labelledby="move-heading">
<h2 id="move-heading">{duel.you.alive ? 'You have yielded' : 'You have fallen'}</h2> <h2 id="move-heading">{duel.you.alive ? 'You have yielded' : 'You have fallen'}</h2>
<p class="threats muted">The duel goes on without you. Watch the ledger fill; {listText(duel.others.filter((id) => duel.state.wizards[id].alive && !duel.state.wizards[id].surrendered).map((id) => duel.state.wizards[id].name))} still stand.</p> <p class="threats muted">The duel goes on without you. Watch the ledger fill; {listText(duel.others.filter((id) => duel.state.wizards[id].alive && !duel.state.wizards[id].surrendered).map((id) => duel.state.wizards[id].name))} still stand.</p>
@@ -200,10 +206,13 @@
</section> </section>
<aside class="rail"> <aside class="rail">
<WizardStatus wizard={duel.you} monsters={duel.yourMonsters} you /> {#if !duel.spectating}
<WizardStatus wizard={duel.you} monsters={duel.yourMonsters} you />
{/if}
{#each duel.others as id (id)} {#each duel.others as id (id)}
<WizardStatus wizard={duel.state.wizards[id]} monsters={duel.state.monsters.filter((m) => m.owner === id)} /> <WizardStatus wizard={duel.state.wizards[id]} monsters={duel.state.monsters.filter((m) => m.owner === id)} />
{/each} {/each}
<TableTalk {duel} />
<SpellSheet {duel} /> <SpellSheet {duel} />
<Chronicle log={duel.state.log} /> <Chronicle log={duel.state.log} />
</aside> </aside>
+14 -2
View File
@@ -2,6 +2,7 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { allSeats, api, forgetSeat } from '$lib/game/client'; import { allSeats, api, forgetSeat } from '$lib/game/client';
import { Duel, duel, NAME_MAX, playerName, rememberName } from '$lib/game/duel.svelte'; 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'; import type { RoomView } from '$lib/game/view';
let name = $state(playerName() || duel.you.name); let name = $state(playerName() || duel.you.name);
@@ -92,6 +93,12 @@
return `waiting on ${g.waitingOn.map((id) => g.state!.wizards[id].name).join(', ')} · ${turn}`; return `waiting on ${g.waitingOn.map((id) => g.state!.wizards[id].name).join(', ')} · ${turn}`;
} }
/** Lines of table talk said since this browser last looked in. */
function unread(held: { roomId: string }): number {
const g = games[held.roomId];
return g ? unreadTalk(held.roomId, g.chat.length) : 0;
}
function yourMove(held: { roomId: string; seat: string }): boolean { function yourMove(held: { roomId: string; seat: string }): boolean {
const g = games[held.roomId]; const g = games[held.roomId];
return !!g?.state && !g.state.over && g.waitingOn.includes(held.seat); return !!g?.state && !g.state.over && g.waitingOn.includes(held.seat);
@@ -109,7 +116,7 @@
<section class="lid"> <section class="lid">
<div class="lid-head"> <div class="lid-head">
<h1>Waving Hands</h1> <h1>Waving Hands</h1>
<p class="pitch">Two wizards. Two hands each. The right run of gestures is a spell.</p> <p class="pitch">Duelling wizards. Two hands each. The right run of gestures is a spell.</p>
<p class="tag">Richard Bartle's 1977 pencil-and-paper duel, free in your browser. Each turn every wizard chooses a gesture per hand and all reveal together; a Fireball is F-S-S-D-D on one hand, a Shield is a single palm. Play the bot, or open a table for up to a dozen wizards and play by turns.</p> <p class="tag">Richard Bartle's 1977 pencil-and-paper duel, free in your browser. Each turn every wizard chooses a gesture per hand and all reveal together; a Fireball is F-S-S-D-D on one hand, a Shield is a single palm. Play the bot, or open a table for up to a dozen wizards and play by turns.</p>
<p class="links"><a href="/guide">how to play</a> · <a href="/rules">the rules</a> · <a class="about-link" href="#about" onclick={() => (aboutOpen = true)}>about this game — a labor of love</a></p> <p class="links"><a href="/guide">how to play</a> · <a href="/rules">the rules</a> · <a class="about-link" href="#about" onclick={() => (aboutOpen = true)}>about this game — a labor of love</a></p>
</div> </div>
@@ -135,6 +142,7 @@
<button type="submit" class="quiet" disabled={!ready || code.trim().length < 4}>Join</button> <button type="submit" class="quiet" disabled={!ready || code.trim().length < 4}>Join</button>
</form> </form>
</div> </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>
{#if error}<p class="warning">{error}</p>{/if} {#if error}<p class="warning">{error}</p>{/if}
</div> </div>
@@ -174,7 +182,7 @@
<div class="ledger-row" class:your-move={yourMove(held)}> <div class="ledger-row" class:your-move={yourMove(held)}>
<a class="ledger-resume" href={`/join/${held.roomId}`}> <a class="ledger-resume" href={`/join/${held.roomId}`}>
<span class="ledger-code">{held.roomId}</span> <span class="ledger-code">{held.roomId}</span>
<span class="ledger-info">{status(held)}</span> <span class="ledger-info">{status(held)}{#if unread(held) > 0} · <span class="talk-new">{unread(held)} new {unread(held) === 1 ? 'line' : 'lines'} of talk</span>{/if}</span>
</a> </a>
<button type="button" class="ledger-forget" title="forget this duel" onclick={() => forget(held.roomId)}>×</button> <button type="button" class="ledger-forget" title="forget this duel" onclick={() => forget(held.roomId)}>×</button>
</div> </div>
@@ -428,6 +436,10 @@
text-decoration: underline; text-decoration: underline;
} }
.talk-new {
color: var(--frost);
}
.ledger-code { .ledger-code {
font-weight: 600; font-weight: 600;
letter-spacing: 0.12em; letter-spacing: 0.12em;
+13 -5
View File
@@ -2,7 +2,7 @@
import type { Duel } from '$lib/game/duel.svelte'; import type { Duel } from '$lib/game/duel.svelte';
import { glyph } from '$lib/game/glyphs'; import { glyph } from '$lib/game/glyphs';
import { SPELL_BY_ID, type Hand } from '$lib/game/spells'; import { SPELL_BY_ID, type Hand } from '$lib/game/spells';
import type { HistoryEntry, WizardId } from '$lib/game/state'; import { hiddenFromViewer, type HistoryEntry, type WizardId } from '$lib/game/state';
let { duel }: { duel: Duel } = $props(); let { duel }: { duel: Duel } = $props();
@@ -21,8 +21,8 @@
cells: Record<WizardId, Placed[]>; cells: Record<WizardId, Placed[]>;
} }
/** You first, then the others in table order. */ /** You first, then the others in table order; the gallery sees the table as laid. */
const columns = $derived<WizardId[]>([duel.me, ...duel.others]); const columns = $derived<WizardId[]>(duel.spectating ? duel.others : [duel.me, ...duel.others]);
function placed(who: WizardId, turn: number, phases: string[]): Placed[] { function placed(who: WizardId, turn: number, phases: string[]): Placed[] {
const history = who === duel.me ? duel.you.history : duel.visible(who); const history = who === duel.me ? duel.you.history : duel.visible(who);
@@ -30,7 +30,7 @@
const out: Placed[] = []; const out: Placed[] = [];
history.forEach((entry, index) => { history.forEach((entry, index) => {
if (entry.turn === turn && phases.includes(entry.phase)) { if (entry.turn === turn && phases.includes(entry.phase)) {
out.push({ entry, index, hidden: raw[index].hiddenFrom.includes(duel.me) }); out.push({ entry, index, hidden: hiddenFromViewer(raw[index].hiddenFrom, duel.me) });
} }
}); });
return out; return out;
@@ -110,7 +110,15 @@
{/each} {/each}
</tr> </tr>
{/each} {/each}
{#if drafting} {#if drafting && duel.spectating}
<tr class="draft">
<td class="turn">{duel.turnNumber}</td>
{#each columns as who (who)}
<td class="cell foe"><span class="letter placeholder">?</span></td>
<td class="cell foe"><span class="letter placeholder">?</span></td>
{/each}
</tr>
{:else if drafting}
<tr class="draft" class:stopped={duel.timeStopped}> <tr class="draft" class:stopped={duel.timeStopped}>
<td class="turn">{duel.timeStopped ? '' : duel.turnNumber}{#if duel.timeStopped}<span class="rownote">time stop</span>{/if}</td> <td class="turn">{duel.timeStopped ? '' : duel.turnNumber}{#if duel.timeStopped}<span class="rownote">time stop</span>{/if}</td>
{#each ['left', 'right'] as const as hand (hand)} {#each ['left', 'right'] as const as hand (hand)}
+24 -10
View File
@@ -7,22 +7,26 @@
</script> </script>
{#if !duel.state.over} {#if !duel.state.over}
<div class="bar"> <div class="bar" class:watching={duel.spectating}>
<div class="health" class:many={duel.others.length > 1}> <div class="health" class:many={duel.others.length > 1}>
<span><em>{duel.you.name}</em> {duel.you.hp}/{MAX_HP}</span> {#if !duel.spectating}<span><em>{duel.you.name}</em> {duel.you.hp}/{MAX_HP}</span>{/if}
{#each duel.others as id (id)} {#each duel.others as id (id)}
{@const w = duel.state.wizards[id]} {@const w = duel.state.wizards[id]}
<span class:gone={!w.alive || w.surrendered}><em>{w.name}</em> {w.hp}/{MAX_HP}</span> <span class:gone={!w.alive || w.surrendered}><em>{w.name}</em> {w.hp}/{MAX_HP}</span>
{/each} {/each}
</div> </div>
<div class="gestures" aria-label="Chosen gestures"> {#if duel.spectating}
{#each HANDS as hand (hand)} <span class="gallery">the gallery</span>
<span class="letter" class:empty={duel.chosenGesture('main', hand) === null}>{duel.drawn('main', hand)}</span> {:else}
{/each} <div class="gestures" aria-label="Chosen gestures">
</div> {#each HANDS as hand (hand)}
<button type="button" class="reveal" disabled={!duel.ready} onclick={() => duel.reveal()}> <span class="letter" class:empty={duel.chosenGesture('main', hand) === null}>{duel.drawn('main', hand)}</span>
{duel.out ? 'Out' : duel.moved ? 'Waiting' : duel.surrendering ? 'Surrender' : duel.timeStopped ? 'Act' : 'Reveal'} {/each}
</button> </div>
<button type="button" class="reveal" disabled={!duel.ready} onclick={() => duel.reveal()}>
{duel.out ? 'Out' : duel.moved ? 'Waiting' : duel.surrendering ? 'Surrender' : duel.timeStopped ? 'Act' : 'Reveal'}
</button>
{/if}
</div> </div>
{/if} {/if}
@@ -49,6 +53,16 @@
} }
} }
.bar.watching {
grid-template-columns: 1fr auto;
}
.gallery {
font-size: 0.8rem;
font-style: italic;
color: var(--bone-faint);
}
.health { .health {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+124
View File
@@ -0,0 +1,124 @@
<script lang="ts">
import { tick } from 'svelte';
import 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('');
let sending = $state(false);
let list = $state<HTMLElement | null>(null);
// New talk scrolls into view, as at any table.
$effect(() => {
void lines.length;
void tick().then(() => {
if (list) list.scrollTop = list.scrollHeight;
});
});
async function say(e: SubmitEvent) {
e.preventDefault();
const text = draft.trim();
if (!text || sending) return;
sending = true;
if (await duel.say(text)) draft = '';
sending = false;
}
</script>
{#if remote}
<section class="talk" aria-label="Table talk">
<h2>Table talk{#if remote.audience > 0}<span class="audience">{remote.audience} in the gallery</span>{/if}</h2>
<ul bind:this={list}>
{#if lines.length === 0}
<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>
{/each}
</ul>
{#if remote.spectating}
<p class="muted small">The gallery listens; only seated wizards speak.</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" />
<button type="submit" class="quiet" disabled={!draft.trim() || sending}>Say</button>
</form>
{/if}
</section>
{/if}
<style>
.talk {
padding: 0.75rem 0;
border-top: 1px solid var(--rule);
}
h2 {
font-size: 1.1rem;
font-style: italic;
margin-bottom: 0.4rem;
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 0.75rem;
}
.audience {
font-size: 0.8rem;
font-style: normal;
color: var(--bone-faint);
}
ul {
list-style: none;
margin: 0;
padding: 0;
max-height: 12rem;
overflow-y: auto;
font-size: 0.9rem;
line-height: 1.4;
}
li + li {
margin-top: 0.2rem;
}
li em {
color: var(--bone-dim);
}
li.you em {
color: var(--frost);
}
form {
display: flex;
gap: 0.4rem;
margin-top: 0.5rem;
}
input {
flex: 1;
min-width: 0;
}
.quiet {
background: none;
border: 1px solid var(--rule-strong);
border-radius: 4px;
padding: 0.3rem 0.8rem;
color: var(--bone);
}
.quiet:disabled {
opacity: 0.5;
}
.small {
font-size: 0.85rem;
margin-top: 0.4rem;
}
</style>
+7 -3
View File
@@ -74,10 +74,13 @@ export const api = {
addBot: (roomId: string, token: string) => call<RoomView>('POST', `/api/rooms/${roomId}/bot`, { token }), addBot: (roomId: string, token: string) => call<RoomView>('POST', `/api/rooms/${roomId}/bot`, { token }),
begin: (roomId: string, token: string) => call<RoomView>('POST', `/api/rooms/${roomId}/begin`, { token }), begin: (roomId: string, token: string) => call<RoomView>('POST', `/api/rooms/${roomId}/begin`, { token }),
view: (roomId: string, token: string) => call<RoomView>('GET', `/api/rooms/${roomId}?token=${encodeURIComponent(token)}`), view: (roomId: string, token: string) => call<RoomView>('GET', `/api/rooms/${roomId}?token=${encodeURIComponent(token)}`),
turn: (roomId: string, token: string, input: TurnInput) => call<RoomView>('POST', `/api/rooms/${roomId}/turn`, { token, input }) /** The gallery's view: no token, no seat, nothing any wizard could not see. */
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 socket that only ever says "fetch the view again"; reconnects on its own. */ /** A socket that only ever says "fetch the view again"; reconnects on its own. An empty token sits in the gallery. */
export function watchRoom(roomId: string, token: string, onUpdate: () => void, onStatus: (open: boolean) => void): () => void { export function watchRoom(roomId: string, token: string, onUpdate: () => void, onStatus: (open: boolean) => void): () => void {
let socket: WebSocket | null = null; let socket: WebSocket | null = null;
let closed = false; let closed = false;
@@ -85,7 +88,8 @@ export function watchRoom(roomId: string, token: string, onUpdate: () => void, o
const open = () => { const open = () => {
if (closed) return; if (closed) return;
const protocol = location.protocol === 'https:' ? 'wss' : 'ws'; const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
socket = new WebSocket(`${protocol}://${location.host}/ws?room=${encodeURIComponent(roomId)}&token=${encodeURIComponent(token)}`); const seat = token ? `&token=${encodeURIComponent(token)}` : '';
socket = new WebSocket(`${protocol}://${location.host}/ws?room=${encodeURIComponent(roomId)}${seat}`);
socket.onopen = () => { socket.onopen = () => {
delay = 1000; delay = 1000;
onStatus(true); onStatus(true);
+48 -5
View File
@@ -5,6 +5,7 @@
import { tick } from 'svelte'; import { tick } from 'svelte';
import { chooseBotTurn } from './bot'; import { chooseBotTurn } from './bot';
import { api, rememberSeat, watchRoom, type HeldSeat } from './client'; import { api, rememberSeat, watchRoom, type HeldSeat } from './client';
import { markTalkSeen } from './talk';
import { import {
cellsOverlap, cellsOverlap,
completableIn, completableIn,
@@ -33,6 +34,7 @@ import {
} from './spells'; } from './spells';
import { import {
NOWHERE, NOWHERE,
SPECTATOR,
createGame, createGame,
enemyOf, enemyOf,
inDuel, inDuel,
@@ -44,7 +46,7 @@ import {
type TurnInput, type TurnInput,
type WizardId type WizardId
} from './state'; } from './state';
import type { RoomView } from './view'; import type { ChatLine, RoomView } from './view';
/** The seats of a duel played in this browser against the bot. */ /** The seats of a duel played in this browser against the bot. */
const LOCAL_ME: WizardId = 'A'; const LOCAL_ME: WizardId = 'A';
@@ -214,6 +216,11 @@ export interface Remote extends HeldSeat {
started: boolean; started: boolean;
connected: boolean; connected: boolean;
error: string; error: string;
chat: ChatLine[];
/** How many watch from the Peanut Gallery. */
audience: number;
/** Watching from the gallery: no seat, no hands, no voice. */
spectating: boolean;
} }
export class Duel { export class Duel {
@@ -249,7 +256,7 @@ export class Duel {
if (initial && 'roomId' in initial) { if (initial && 'roomId' in initial) {
this.remote = initial; this.remote = initial;
this.me = initial.seat; this.me = initial.seat;
this.state = createGame({ [initial.seat]: 'You' }); this.state = createGame({ [initial.seat]: initial.spectating ? 'The gallery' : 'You' });
} else if (initial) { } else if (initial) {
this.state = initial; this.state = initial;
} else if (saved) { } else if (saved) {
@@ -313,7 +320,10 @@ export class Duel {
/** A duel worth asking about before it is thrown away. */ /** A duel worth asking about before it is thrown away. */
inProgress = $derived(this.state.turn > 0 && !this.state.over); inProgress = $derived(this.state.turn > 0 && !this.state.over);
you = $derived(this.state.wizards[this.me]); /** Watching from the gallery, with no seat at the table. */
spectating = $derived(this.remote?.spectating ?? false);
/** The gallery has no wizard of its own; it borrows the first seat for the fields that need one, and shows none of them. */
you = $derived(this.state.wizards[this.me] ?? this.state.wizards[this.state.seats[0]]);
/** The opponent spells go at by default: the first still standing. */ /** The opponent spells go at by default: the first still standing. */
foeId = $derived(enemyOf(this.state, this.me)); foeId = $derived(enemyOf(this.state, this.me));
foe = $derived(this.state.wizards[this.foeId]); foe = $derived(this.state.wizards[this.foeId]);
@@ -723,6 +733,25 @@ export class Duel {
} }
} }
/** What a seat is called, for a line of talk. */
speaker(id: WizardId): string {
return this.remote?.seats.find((s) => s.id === id)?.name ?? this.state.wizards[id]?.name ?? id;
}
/** Say something to the whole table. Resolves false when the words did not arrive. */
async say(text: string): Promise<boolean> {
const r = this.remote;
if (!r || r.spectating) return false;
r.error = '';
try {
this.apply(await api.say(r.roomId, r.token, text));
return true;
} catch (e) {
r.error = e instanceof Error ? e.message : 'The table did not hear you.';
return false;
}
}
/** Take a fresh view from the server; a new turn clears the drafts. */ /** Take a fresh view from the server; a new turn clears the drafts. */
apply(view: RoomView): void { apply(view: RoomView): void {
const r = this.remote!; const r = this.remote!;
@@ -730,6 +759,9 @@ export class Duel {
r.size = view.size; r.size = view.size;
r.host = view.host; r.host = view.host;
r.waitingOn = view.waitingOn; r.waitingOn = view.waitingOn;
r.chat = view.chat;
r.audience = view.audience;
markTalkSeen(r.roomId, view.chat.length);
const wasStarted = r.started; const wasStarted = r.started;
r.started = view.state !== null; r.started = view.state !== null;
if (!view.state) return; if (!view.state) return;
@@ -742,8 +774,11 @@ export class Duel {
connect(): () => void { connect(): () => void {
const r = this.remote; const r = this.remote;
if (!r) return () => {}; if (!r) return () => {};
const fetchView = () => (r.spectating ? api.watch(r.roomId) : api.view(r.roomId, r.token));
const refresh = () => { const refresh = () => {
api.view(r.roomId, r.token).then((v) => this.apply(v)).catch((e: Error) => (r.error = e.message)); fetchView()
.then((v) => this.apply(v))
.catch((e: Error) => (r.error = e.message));
}; };
return watchRoom(r.roomId, r.token, refresh, (open) => (r.connected = open)); return watchRoom(r.roomId, r.token, refresh, (open) => (r.connected = open));
} }
@@ -844,6 +879,11 @@ export class Duel {
static async resumeRoom(roomId: string, held: HeldSeat): Promise<Remote> { static async resumeRoom(roomId: string, held: HeldSeat): Promise<Remote> {
return remoteFrom(await api.view(roomId, held.token), held.token); return remoteFrom(await api.view(roomId, held.token), held.token);
} }
/** Take a place in the Peanut Gallery: watch a table with no seat and no voice. */
static async watchRoom(roomId: string): Promise<Remote> {
return remoteFrom(await api.watch(roomId), '');
}
} }
function remoteFrom(view: RoomView, token: string): Remote { function remoteFrom(view: RoomView, token: string): Remote {
@@ -857,7 +897,10 @@ function remoteFrom(view: RoomView, token: string): Remote {
waitingOn: view.waitingOn, waitingOn: view.waitingOn,
started: view.state !== null, started: view.state !== null,
connected: false, connected: false,
error: '' error: '',
chat: view.chat,
audience: view.audience,
spectating: view.me === SPECTATOR
}; };
} }
+9 -3
View File
@@ -288,7 +288,13 @@ export function beingName(state: GameState, id: TargetId | undefined): string {
*/ */
export function visibleHistory(state: GameState, viewer: WizardId, subject: WizardId): HistoryEntry[] { export function visibleHistory(state: GameState, viewer: WizardId, subject: WizardId): HistoryEntry[] {
if (viewer === subject) return state.wizards[subject].history; if (viewer === subject) return state.wizards[subject].history;
return state.wizards[subject].history.map((h) => return state.wizards[subject].history.map((h) => (hiddenFromViewer(h.hiddenFrom, viewer) ? { ...h, left: '-', right: '-' } : h));
h.hiddenFrom.includes(viewer) ? { ...h, left: '-', right: '-' } : h }
);
/** The gallery holds no seat: it is shown only what every wizard at the table could see. */
export const SPECTATOR: WizardId = '';
export function hiddenFromViewer(hiddenFrom: WizardId[] | undefined, viewer: WizardId): boolean {
if (!hiddenFrom?.length) return false;
return viewer === SPECTATOR || hiddenFrom.includes(viewer);
} }
+25
View File
@@ -0,0 +1,25 @@
// How much of each table's talk this browser has read, so the hall can say what is new.
const SEEN_KEY = 'wh:talk-seen';
function seen(): Record<string, number> {
try {
return JSON.parse(localStorage.getItem(SEEN_KEY) ?? '{}') as Record<string, number>;
} catch {
return {};
}
}
export function markTalkSeen(roomId: string, count: number): void {
try {
const all = seen();
if ((all[roomId] ?? 0) >= count) return;
localStorage.setItem(SEEN_KEY, JSON.stringify({ ...all, [roomId]: count }));
} catch {
// Without storage every line reads as new, which is the safe side.
}
}
export function unreadTalk(roomId: string, count: number): number {
return Math.max(0, count - (seen()[roomId] ?? 0));
}
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { SPECTATOR, createGame } from './state';
import { viewFor } from './view';
/** Three wizards; B's first gestures were hidden from C alone. */
function tableWithASecret() {
const s = createGame({ A: 'Aldric', B: 'Bryn', C: 'Cass' }, 1);
s.wizards.B.history.push({ left: 'P', right: 'S', turn: 0, phase: 'main', hiddenFrom: ['C'] });
s.log.push({ turn: 0, kind: 'gesture', text: 'Bryn: left proffered palm, right snap.', hiddenFrom: ['C'] });
s.log.push({ turn: 0, kind: 'info', text: 'Nothing else happened.' });
return s;
}
describe('the view a room sends', () => {
it('shows a hidden gesture to the wizards who could see it', () => {
const view = viewFor(tableWithASecret(), 'A');
expect(view.wizards.B.history[0].left).toBe('P');
expect(view.log).toHaveLength(2);
});
it('withholds it from the wizard it was hidden from', () => {
const view = viewFor(tableWithASecret(), 'C');
expect(view.wizards.B.history[0].left).toBe('-');
expect(view.log.map((l) => l.text)).toEqual(['Nothing else happened.']);
});
it('withholds from the gallery anything hidden from anyone', () => {
const view = viewFor(tableWithASecret(), SPECTATOR);
expect(view.wizards.B.history[0].left).toBe('-');
expect(view.log.map((l) => l.text)).toEqual(['Nothing else happened.']);
});
});
+14 -3
View File
@@ -3,7 +3,7 @@
// describe those gestures are withheld too. Spells cast are always visible, as // describe those gestures are withheld too. Spells cast are always visible, as
// the rules say. // the rules say.
import { visibleHistory, type GameState, type WizardId } from './state'; import { hiddenFromViewer, visibleHistory, type GameState, type WizardId } from './state';
export function viewFor(state: GameState, viewer: WizardId): GameState { export function viewFor(state: GameState, viewer: WizardId): GameState {
const view = structuredClone(state); const view = structuredClone(state);
@@ -11,16 +11,24 @@ export function viewFor(state: GameState, viewer: WizardId): GameState {
if (id === viewer) continue; if (id === viewer) continue;
view.wizards[id].history = visibleHistory(state, viewer, id); view.wizards[id].history = visibleHistory(state, viewer, id);
} }
view.log = view.log.filter((l) => !l.hiddenFrom?.includes(viewer)); view.log = view.log.filter((l) => !hiddenFromViewer(l.hiddenFrom, viewer));
return view; return view;
} }
/** What a seated player is told by the server. Other seats' moves and tokens never leave it. */ /** One line of table talk. Only seated wizards speak; the gallery listens. */
export interface ChatLine {
id: WizardId;
text: string;
at: number;
}
/** What a seated player, or the gallery, is told by the server. Other seats' moves and tokens never leave it. */
export interface RoomView { export interface RoomView {
roomId: string; roomId: string;
seq: number; seq: number;
/** The most seats the table will take. */ /** The most seats the table will take. */
size: number; size: number;
/** The viewer's seat, or SPECTATOR for the gallery. */
me: WizardId; me: WizardId;
/** The wizard who opened the table and may begin the duel. */ /** The wizard who opened the table and may begin the duel. */
host: WizardId; host: WizardId;
@@ -28,4 +36,7 @@ export interface RoomView {
/** Seats that still have to move before the turn resolves. */ /** Seats that still have to move before the turn resolves. */
waitingOn: WizardId[]; waitingOn: WizardId[];
state: GameState | null; state: GameState | null;
chat: ChatLine[];
/** How many watch from the Peanut Gallery. */
audience: number;
} }
+7
View File
@@ -16,6 +16,7 @@
<a href="#reveal">The reveal</a> <a href="#reveal">The reveal</a>
<a href="#rail">Health, creatures and the sheet</a> <a href="#rail">Health, creatures and the sheet</a>
<a href="#phone">On a phone</a> <a href="#phone">On a phone</a>
<a href="#gallery">The Peanut Gallery and table talk</a>
<a href="#first">A first duel</a> <a href="#first">A first duel</a>
</nav> </nav>
</header> </header>
@@ -97,6 +98,12 @@
<p>Everything is here, in one column. The bar at the foot keeps every wizard's health, your two chosen gestures and the reveal button in reach; after a reveal the page brings the ledger's last rows and the result into view. Lists are shorter, and <em>More spells</em> opens the rest.</p> <p>Everything is here, in one column. The bar at the foot keeps every wizard's health, your two chosen gestures and the reveal button in reach; after a reveal the page brings the ledger's last rows and the result into view. Lists are shorter, and <em>More spells</em> opens the rest.</p>
</section> </section>
<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>
</section>
<section id="first"> <section id="first">
<h2>A first duel</h2> <h2>A first duel</h2>
<p>Open with a wave on each hand. From W-W you may turn either hand into a counter-spell (W-W-S), a protection from evil (W-W-P) or resist heat (W-W-F-P), and nobody can hurt you on the first turn. Watch the warnings: when a missile is one gesture away, a palm costs you one hand for one turn; when a fireball is one gesture away, only a counter-spell, a resistance, or a counter of your own will do.</p> <p>Open with a wave on each hand. From W-W you may turn either hand into a counter-spell (W-W-S), a protection from evil (W-W-P) or resist heat (W-W-F-P), and nobody can hurt you on the first turn. Watch the warnings: when a missile is one gesture away, a palm costs you one hand for one turn; when a fireball is one gesture away, only a counter-spell, a resistance, or a counter of your own will do.</p>
+67 -23
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import Board from '$lib/components/Board.svelte'; import Board from '$lib/components/Board.svelte';
import TableTalk from '$lib/components/TableTalk.svelte';
import { seatFor } from '$lib/game/client'; import { seatFor } from '$lib/game/client';
import { COMPACT_QUERY, Duel, NAME_MAX, playerName } from '$lib/game/duel.svelte'; import { COMPACT_QUERY, Duel, NAME_MAX, playerName } from '$lib/game/duel.svelte';
@@ -15,8 +16,11 @@
const link = $derived(typeof location === 'undefined' ? '' : `${location.origin}/join/${roomId}`); const link = $derived(typeof location === 'undefined' ? '' : `${location.origin}/join/${roomId}`);
const lobby = $derived(room !== null && !room.remote?.started); const lobby = $derived(room !== null && !room.remote?.started);
const watching = $derived(room?.spectating ?? false);
/** A watcher may still sit while the table is laid and a seat is free. */
const seatFree = $derived(!!room?.remote && !room.remote.started && room.remote.seats.length < room.remote.size);
// Return to a held seat, or offer one. // Return to a held seat, or take a place in the gallery and offer one.
$effect(() => { $effect(() => {
const id = roomId; const id = roomId;
room = null; room = null;
@@ -25,6 +29,9 @@
if (!held) { if (!held) {
name = playerName(); name = playerName();
joining = true; joining = true;
Duel.watchRoom(id)
.then((remote) => (room = new Duel(remote)))
.catch((e: Error) => (error = e.message));
return; return;
} }
joining = false; joining = false;
@@ -84,7 +91,13 @@
<header class="masthead"> <header class="masthead">
<div> <div>
<h1>Waving Hands</h1> <h1>Waving Hands</h1>
{#if room?.remote?.started} {#if room?.remote?.started && watching}
<p class="standfirst">
From the Peanut Gallery you watch
{#each room.others as id, i (id)}{#if i > 0}{i === room.others.length - 1 ? ' and ' : ', '}{/if}<em>{room.state.wizards[id].name}</em>{/each} duel.
{#if !room.remote.connected}<span class="muted">Reconnecting to the duel.</span>{/if}
</p>
{:else if room?.remote?.started}
<p class="standfirst"> <p class="standfirst">
You are <em>{room.you.name}</em>, duelling You are <em>{room.you.name}</em>, duelling
{#each room.others as id, i (id)}{#if i > 0}{i === room.others.length - 1 ? ' and ' : ', '}{/if}<em>{room.state.wizards[id].name}</em>{/each}. {#each room.others as id, i (id)}{#if i > 0}{i === room.others.length - 1 ? ' and ' : ', '}{/if}<em>{room.state.wizards[id].name}</em>{/each}.
@@ -97,8 +110,11 @@
<nav class="actions"> <nav class="actions">
{#if room} {#if room}
<button type="button" class="quiet room" title="Copy the invite link" onclick={copyLink}> <button type="button" class="quiet room" title="Copy the invite link" onclick={copyLink}>
<span class="muted">invite friends · room</span> <b>{roomId}</b>{copied ? ' · link copied' : ''} <span class="muted">{watching ? 'watching · room' : 'invite friends · room'}</span> <b>{roomId}</b>{copied ? ' · link copied' : ''}
</button> </button>
{#if room.remote && room.remote.audience > 0}
<span class="audience" title="the Peanut Gallery">{room.remote.audience} in the gallery</span>
{/if}
{/if} {/if}
<a class="quiet" href="/guide">How to play</a> <a class="quiet" href="/guide">How to play</a>
<a class="quiet" href="/rules">Rules</a> <a class="quiet" href="/rules">Rules</a>
@@ -110,11 +126,11 @@
<p class="notice">{error}</p> <p class="notice">{error}</p>
{/if} {/if}
{#if joining} {#if joining && seatFree}
<section class="lobby"> <section class="lobby">
<p class="eyebrow">room {roomId}</p> <p class="eyebrow">room {roomId}</p>
<h2>Take a seat</h2> <h2>Take a seat</h2>
<p>A wizard has opened this table and is gathering opponents.</p> <p>A wizard has opened this table and is gathering opponents. Choose a name and sit; the duel begins when the host says.</p>
<form class="rename" onsubmit={takeSeat}> <form class="rename" onsubmit={takeSeat}>
<label class="field"> <label class="field">
<span>Your name</span> <span>Your name</span>
@@ -122,15 +138,27 @@
</label> </label>
<button type="submit" class="reveal small" disabled={busy}>Sit down</button> <button type="submit" class="reveal small" disabled={busy}>Sit down</button>
</form> </form>
<p class="muted">Just watching? You are in the Peanut Gallery until you sit.</p>
</section> </section>
{:else if room && lobby} {/if}
{#if room && lobby}
{@const r = room.remote!} {@const r = room.remote!}
{@const hostName = r.seats.find((s) => s.id === r.host)?.name ?? 'the host'} {@const hostName = r.seats.find((s) => s.id === r.host)?.name ?? 'the host'}
<section class="lobby"> <section class="lobby">
<p class="eyebrow">room {roomId}</p> {#if watching}
<h2>The table is laid</h2> <h2>At the table</h2>
<p>Send this link, or the code. Whoever opens it takes a seat; the table holds a dozen.</p> {#if seatFree}
<p class="link-row"><code>{link}</code><button type="button" class="quiet" onclick={copyLink}>{copied ? 'Copied' : 'Copy link'}</button></p> <p>{hostName} is gathering wizards. Sit down above, or watch from the gallery until the duel begins.</p>
{:else}
<p>The table is full. You watch from the Peanut Gallery; the duel begins when {hostName} says.</p>
{/if}
{:else}
<p class="eyebrow">room {roomId}</p>
<h2>The table is laid</h2>
<p>Send this link, or the code. Whoever opens it takes a seat; the table holds a dozen. Anyone who arrives once the duel has begun watches from the Peanut Gallery.</p>
<p class="link-row"><code>{link}</code><button type="button" class="quiet" onclick={copyLink}>{copied ? 'Copied' : 'Copy link'}</button></p>
{/if}
<ul class="roster"> <ul class="roster">
{#each r.seats as s (s.id)} {#each r.seats as s (s.id)}
<li> <li>
@@ -138,25 +166,28 @@
<span class="muted">{s.id === r.host ? 'opened the table' : s.bot ? 'the bot' : 'seated'}{s.id === r.seat ? ', you' : ''}</span> <span class="muted">{s.id === r.host ? 'opened the table' : s.bot ? 'the bot' : 'seated'}{s.id === r.seat ? ', you' : ''}</span>
</li> </li>
{/each} {/each}
<li class="empty muted">{r.size - r.seats.length} of {r.size} seats empty</li> <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>
</ul> </ul>
<div class="ways"> {#if !watching}
{#if r.seats.length < r.size} <div class="ways">
<button type="button" class="quiet" onclick={() => room?.addBot()}>Seat a bot</button> {#if r.seats.length < r.size}
{/if} <button type="button" class="quiet" onclick={() => room?.addBot()}>Seat a bot</button>
{#if room.isHost} {/if}
<button type="button" class="reveal small" disabled={r.seats.length < 2} onclick={() => room?.begin()}>Begin the duel</button> {#if room.isHost}
<span class="muted">{r.seats.length < 2 ? 'once a second wizard is seated' : `with ${r.seats.length} wizards`}</span> <button type="button" class="reveal small" disabled={r.seats.length < 2} onclick={() => room?.begin()}>Begin the duel</button>
{:else} <span class="muted">{r.seats.length < 2 ? 'once a second wizard is seated' : `with ${r.seats.length} wizards`}</span>
<span class="muted">{hostName} will begin the duel when the table is ready.</span> {:else}
{/if} <span class="muted">{hostName} will begin the duel when the table is ready.</span>
</div> {/if}
</div>
{/if}
{#if r.error}<p class="notice">{r.error}</p>{/if} {#if r.error}<p class="notice">{r.error}</p>{/if}
<div class="lobby-talk"><TableTalk duel={room} /></div>
</section> </section>
{:else if room} {:else if room}
<Board duel={room} /> <Board duel={room} />
{:else if !error} {:else if !error}
<p class="notice muted">Finding your seat.</p> <p class="notice muted">{joining ? 'Finding the table.' : 'Finding your seat.'}</p>
{/if} {/if}
<footer class="colophon"> <footer class="colophon">
@@ -217,6 +248,19 @@
font-weight: 600; font-weight: 600;
} }
.audience {
align-self: center;
font-size: 0.85rem;
font-style: italic;
color: var(--bone-faint);
white-space: nowrap;
}
.lobby-talk {
max-width: 28em;
margin-top: 1rem;
}
.eyebrow { .eyebrow {
font-size: 0.85rem; font-size: 0.85rem;
letter-spacing: 0.12em; letter-spacing: 0.12em;