diff --git a/.claude/skills/waving-hands-visitors/SKILL.md b/.claude/skills/waving-hands-visitors/SKILL.md index e15fb78..f096d4c 100644 --- a/.claude/skills/waving-hands-visitors/SKILL.md +++ b/.claude/skills/waving-hands-visitors/SKILL.md @@ -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. - **rooms today**: one line each: time, code, humans vs bots, empty 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 diff --git a/README.md b/README.md index 0433981..632a273 100644 --- a/README.md +++ b/README.md @@ -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 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 -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: diff --git a/deploy/visitors.sh b/deploy/visitors.sh index 3bbcd22..ff6144a 100755 --- a/deploy/visitors.sh +++ b/deploy/visitors.sh @@ -81,7 +81,7 @@ for f in glob.glob("/var/lib/waving-hands/rooms/*.jsonl"): except Exception: continue 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 = {} for x in lines[1:]: if x["t"] == "seat": @@ -92,12 +92,14 @@ for f in glob.glob("/var/lib/waving-hands/rooms/*.jsonl"): elif x["t"] == "turn": turns += 1 last = datetime.datetime.fromtimestamp(x["at"] / 1000, datetime.timezone.utc) + elif x["t"] == "chat": + talk += 1 elif x["t"] == "over": over = "a draw" if x["winner"] is None else "%s won" % names.get(x["winner"], x["winner"]) for h in humans: if h not in first_seen or created < first_seen[h]: 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"]) 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 "" 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"]) - 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 "", - 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 diff --git a/server/src/index.ts b/server/src/index.ts index 67ae11d..d0170e9 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -5,9 +5,11 @@ // 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/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 -// 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. @@ -15,7 +17,7 @@ import * as Sentry from '@sentry/node'; import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; import { WebSocketServer, WebSocket } from 'ws'; import { RateLimit } from './ratelimit'; -import { RoomError, Rooms } from './rooms'; +import { RoomError, Rooms, SPECTATOR } from './rooms'; import { Store } from './store'; const PORT = Number(process.env.PORT ?? 8788); @@ -35,10 +37,14 @@ process.on('unhandledRejection', (reason) => { 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. */ 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; setInterval(() => { const n = rooms.evictIdle(IDLE_ROOM_MS); doors.prune(); + voices.prune(); if (n) console.log(`evicted ${n} idle room${n === 1 ? '' : 's'} from memory; ${rooms.loaded} loaded`); }, 60 * 60 * 1000).unref(); @@ -95,8 +101,9 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise const room = rooms.get(parts[2] ?? ''); const action = parts[3]; if (!action && req.method === 'GET') { - const seat = rooms.seatOf(room, url.searchParams.get('token') ?? undefined); - return send(res, 200, rooms.view(room, seat.id)); + const token = url.searchParams.get('token'); + 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); const body = await readBody(req); @@ -122,6 +129,13 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise const seat = rooms.seatOf(room, token); 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); } @@ -139,17 +153,20 @@ server.on('upgrade', (req, socket, head) => { const url = new URL(req.url ?? '/', 'http://localhost'); if (url.pathname !== '/ws') return socket.destroy(); let seatId: string; - let roomId: string; + let room: ReturnType; try { - roomId = url.searchParams.get('room') ?? ''; - seatId = rooms.seatOf(rooms.get(roomId), url.searchParams.get('token') ?? undefined).id; + room = rooms.get(url.searchParams.get('room') ?? ''); + 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 { return socket.destroy(); } wss.handleUpgrade(req, socket, head, (ws: WebSocket) => { - const unsubscribe = rooms.subscribe(roomId, (room) => { - if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'update', seq: room.seq })); - }); + const onChange = (changed: ReturnType) => { + 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.send(JSON.stringify({ type: 'hello', seat: seatId })); }); diff --git a/server/src/rooms.ts b/server/src/rooms.ts index 67dce19..f2ec664 100644 --- a/server/src/rooms.ts +++ b/server/src/rooms.ts @@ -7,14 +7,18 @@ import { randomBytes } from 'node:crypto'; import { chooseBotTurn } from '../../src/lib/game/bot'; import { resolveTurn } from '../../src/lib/game/resolve'; import { GESTURES, type Gesture } from '../../src/lib/game/spells'; -import { createGame, inDuel, type GameState, type TurnInput, type WizardId } from '../../src/lib/game/state'; -import { viewFor, type RoomView } from '../../src/lib/game/view'; +import { createGame, inDuel, SPECTATOR, type GameState, type TurnInput, type WizardId } from '../../src/lib/game/state'; +import { viewFor, type ChatLine, type RoomView } from '../../src/lib/game/view'; 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. */ const SEAT_IDS: WizardId[] = 'ABCDEFGHJKLM'.split(''); const BOT_NAMES = ['Aldric', 'Morwenna', 'Thessaly', 'Gandric', 'Ysolde', 'Ormund', 'Corwin', 'Isaura', 'Bellamy', 'Hesper', 'Lucan', 'Wynne']; 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 { id: WizardId; @@ -31,6 +35,7 @@ export interface Room { state: GameState | null; /** Inputs received for the turn in progress, by seat. */ pending: Record; + chat: ChatLine[]; /** Bumped on every change a client might want to see. */ seq: number; updatedAt: number; @@ -67,6 +72,8 @@ export function normalizeCode(raw: string): string { export class Rooms { private rooms = new Map(); private listeners = new Map void>>(); + /** Sockets watching from the gallery, by room. */ + private gallery = new Map(); constructor(private store: Store) { 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.`); let 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.commit(room, { t: 'room', id, seats: size, createdAt: room.createdAt }); 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 }); } + /** 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 { return { roomId: room.id, @@ -194,7 +214,9 @@ export class Rooms { host: room.seats[0]?.id ?? seatId, seats: room.seats.map((s) => ({ id: s.id, name: s.name, bot: s.bot })), 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); } + /** 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. */ private commit(room: Room, line: LedgerLine): void { this.store.append(room.id, line); @@ -248,13 +288,17 @@ function apply(room: Room, line: LedgerLine): void { break; case 'over': 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 { const first = lines[0]; if (!first || first.t !== 'room') return null; - const room: Room = { id, size: first.seats, createdAt: first.createdAt, seats: [], state: null, pending: {}, 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) { apply(room, line); if (line.t === 'turn') room.updatedAt = line.at; diff --git a/server/src/store.ts b/server/src/store.ts index 382e5fd..a36b6b5 100644 --- a/server/src/store.ts +++ b/server/src/store.ts @@ -12,7 +12,9 @@ export type LedgerLine = | { t: 'start'; seed: number } | { t: 'turn'; at: number; inputs: Record } /** Written after the turn that ends the duel, for anything that reads ledgers without the engine. */ - | { t: 'over'; at: number; winner: WizardId | null; reason: string }; + | { 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 { constructor(private dir: string) { diff --git a/src/lib/components/Board.svelte b/src/lib/components/Board.svelte index 1c12526..7a045f1 100644 --- a/src/lib/components/Board.svelte +++ b/src/lib/components/Board.svelte @@ -4,6 +4,7 @@ import Ledger from './Ledger.svelte'; import MobileBar from './MobileBar.svelte'; import SpellSheet from './SpellSheet.svelte'; + import TableTalk from './TableTalk.svelte'; import TurnSummary from './TurnSummary.svelte'; import WizardStatus from './WizardStatus.svelte'; import { castKey, listText, tokensText, type Duel } from '$lib/game/duel.svelte'; @@ -24,7 +25,7 @@ {#if outcome}
-

{won ? 'You win.' : outcome.winner === null ? 'A draw.' : 'You lose.'}

+

{duel.spectating ? (outcome.winner === null ? 'A draw.' : `${duel.state.wizards[outcome.winner].name} wins.`) : won ? 'You win.' : outcome.winner === null ? 'A draw.' : 'You lose.'}

{outcome.reason}

{#if duel.remote} Back to the hall @@ -33,7 +34,12 @@ {/if}
{:else} - {#if duel.out} + {#if duel.spectating} +
+

You watch from the Peanut Gallery

+

{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.

+
+ {:else if duel.out}

{duel.you.alive ? 'You have yielded' : 'You have fallen'}

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.

@@ -200,10 +206,13 @@
diff --git a/src/lib/components/Hall.svelte b/src/lib/components/Hall.svelte index e3e8a49..dcd9cc7 100644 --- a/src/lib/components/Hall.svelte +++ b/src/lib/components/Hall.svelte @@ -2,6 +2,7 @@ import { goto } from '$app/navigation'; import { allSeats, api, forgetSeat } from '$lib/game/client'; import { Duel, duel, NAME_MAX, playerName, rememberName } from '$lib/game/duel.svelte'; + import { unreadTalk } from '$lib/game/talk'; import type { RoomView } from '$lib/game/view'; 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}`; } + /** 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 { const g = games[held.roomId]; return !!g?.state && !g.state.over && g.waitingOn.includes(held.seat); @@ -109,7 +116,7 @@

Waving Hands

-

Two wizards. Two hands each. The right run of gestures is a spell.

+

Duelling wizards. Two hands each. The right run of gestures is a spell.

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.

@@ -135,6 +142,7 @@ +

A code opens the door either way: take a seat if one is free, or watch from the Peanut Gallery.

{#if error}

{error}

{/if} @@ -174,7 +182,7 @@ @@ -428,6 +436,10 @@ text-decoration: underline; } + .talk-new { + color: var(--frost); + } + .ledger-code { font-weight: 600; letter-spacing: 0.12em; diff --git a/src/lib/components/Ledger.svelte b/src/lib/components/Ledger.svelte index bc8cf54..0227706 100644 --- a/src/lib/components/Ledger.svelte +++ b/src/lib/components/Ledger.svelte @@ -2,7 +2,7 @@ import type { Duel } from '$lib/game/duel.svelte'; import { glyph } from '$lib/game/glyphs'; 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(); @@ -21,8 +21,8 @@ cells: Record; } - /** You first, then the others in table order. */ - const columns = $derived([duel.me, ...duel.others]); + /** You first, then the others in table order; the gallery sees the table as laid. */ + const columns = $derived(duel.spectating ? duel.others : [duel.me, ...duel.others]); function placed(who: WizardId, turn: number, phases: string[]): Placed[] { const history = who === duel.me ? duel.you.history : duel.visible(who); @@ -30,7 +30,7 @@ const out: Placed[] = []; history.forEach((entry, index) => { 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; @@ -110,7 +110,15 @@ {/each} {/each} - {#if drafting} + {#if drafting && duel.spectating} + + {duel.turnNumber} + {#each columns as who (who)} + ? + ? + {/each} + + {:else if drafting} {duel.timeStopped ? '' : duel.turnNumber}{#if duel.timeStopped}time stop{/if} {#each ['left', 'right'] as const as hand (hand)} diff --git a/src/lib/components/MobileBar.svelte b/src/lib/components/MobileBar.svelte index 8ffeeab..66bfb00 100644 --- a/src/lib/components/MobileBar.svelte +++ b/src/lib/components/MobileBar.svelte @@ -7,22 +7,26 @@ {#if !duel.state.over} -
+
1}> - {duel.you.name} {duel.you.hp}/{MAX_HP} + {#if !duel.spectating}{duel.you.name} {duel.you.hp}/{MAX_HP}{/if} {#each duel.others as id (id)} {@const w = duel.state.wizards[id]} {w.name} {w.hp}/{MAX_HP} {/each}
-
- {#each HANDS as hand (hand)} - {duel.drawn('main', hand)} - {/each} -
- + {#if duel.spectating} + the gallery + {:else} +
+ {#each HANDS as hand (hand)} + {duel.drawn('main', hand)} + {/each} +
+ + {/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 { display: flex; flex-direction: column; diff --git a/src/lib/components/TableTalk.svelte b/src/lib/components/TableTalk.svelte new file mode 100644 index 0000000..d30cc4b --- /dev/null +++ b/src/lib/components/TableTalk.svelte @@ -0,0 +1,124 @@ + + +{#if remote} +
+

Table talk{#if remote.audience > 0}{remote.audience} in the gallery{/if}

+
    + {#if lines.length === 0} +
  • Nobody has said a word.
  • + {/if} + {#each lines as line, i (line.at + ':' + i)} +
  • {line.id === duel.me ? 'You' : duel.speaker(line.id)} {line.text}
  • + {/each} +
+ {#if remote.spectating} +

The gallery listens; only seated wizards speak.

+ {:else} +
+ + +
+ {/if} +
+{/if} + + diff --git a/src/lib/game/client.ts b/src/lib/game/client.ts index 37d120f..2627068 100644 --- a/src/lib/game/client.ts +++ b/src/lib/game/client.ts @@ -74,10 +74,13 @@ export const api = { addBot: (roomId: string, token: string) => call('POST', `/api/rooms/${roomId}/bot`, { token }), begin: (roomId: string, token: string) => call('POST', `/api/rooms/${roomId}/begin`, { token }), view: (roomId: string, token: string) => call('GET', `/api/rooms/${roomId}?token=${encodeURIComponent(token)}`), - turn: (roomId: string, token: string, input: TurnInput) => call('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('GET', `/api/rooms/${roomId}`), + turn: (roomId: string, token: string, input: TurnInput) => call('POST', `/api/rooms/${roomId}/turn`, { token, input }), + say: (roomId: string, token: string, text: string) => call('POST', `/api/rooms/${roomId}/say`, { token, text }) }; -/** A 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 { let socket: WebSocket | null = null; let closed = false; @@ -85,7 +88,8 @@ export function watchRoom(roomId: string, token: string, onUpdate: () => void, o const open = () => { if (closed) return; 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 = () => { delay = 1000; onStatus(true); diff --git a/src/lib/game/duel.svelte.ts b/src/lib/game/duel.svelte.ts index 4cbc0d6..7b4d7fc 100644 --- a/src/lib/game/duel.svelte.ts +++ b/src/lib/game/duel.svelte.ts @@ -5,6 +5,7 @@ import { tick } from 'svelte'; import { chooseBotTurn } from './bot'; import { api, rememberSeat, watchRoom, type HeldSeat } from './client'; +import { markTalkSeen } from './talk'; import { cellsOverlap, completableIn, @@ -33,6 +34,7 @@ import { } from './spells'; import { NOWHERE, + SPECTATOR, createGame, enemyOf, inDuel, @@ -44,7 +46,7 @@ import { type TurnInput, type WizardId } 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. */ const LOCAL_ME: WizardId = 'A'; @@ -214,6 +216,11 @@ export interface Remote extends HeldSeat { started: boolean; connected: boolean; 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 { @@ -249,7 +256,7 @@ export class Duel { if (initial && 'roomId' in initial) { this.remote = initial; this.me = initial.seat; - this.state = createGame({ [initial.seat]: 'You' }); + this.state = createGame({ [initial.seat]: initial.spectating ? 'The gallery' : 'You' }); } else if (initial) { this.state = initial; } else if (saved) { @@ -313,7 +320,10 @@ export class Duel { /** A duel worth asking about before it is thrown away. */ 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. */ foeId = $derived(enemyOf(this.state, this.me)); 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 { + 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. */ apply(view: RoomView): void { const r = this.remote!; @@ -730,6 +759,9 @@ export class Duel { r.size = view.size; r.host = view.host; r.waitingOn = view.waitingOn; + r.chat = view.chat; + r.audience = view.audience; + markTalkSeen(r.roomId, view.chat.length); const wasStarted = r.started; r.started = view.state !== null; if (!view.state) return; @@ -742,8 +774,11 @@ export class Duel { connect(): () => void { const r = this.remote; if (!r) return () => {}; + const fetchView = () => (r.spectating ? api.watch(r.roomId) : api.view(r.roomId, r.token)); 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)); } @@ -844,6 +879,11 @@ export class Duel { static async resumeRoom(roomId: string, held: HeldSeat): Promise { 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 { + return remoteFrom(await api.watch(roomId), ''); + } } function remoteFrom(view: RoomView, token: string): Remote { @@ -857,7 +897,10 @@ function remoteFrom(view: RoomView, token: string): Remote { waitingOn: view.waitingOn, started: view.state !== null, connected: false, - error: '' + error: '', + chat: view.chat, + audience: view.audience, + spectating: view.me === SPECTATOR }; } diff --git a/src/lib/game/state.ts b/src/lib/game/state.ts index 9910903..b920a1b 100644 --- a/src/lib/game/state.ts +++ b/src/lib/game/state.ts @@ -288,7 +288,13 @@ export function beingName(state: GameState, id: TargetId | undefined): string { */ export function visibleHistory(state: GameState, viewer: WizardId, subject: WizardId): HistoryEntry[] { if (viewer === subject) return state.wizards[subject].history; - return state.wizards[subject].history.map((h) => - h.hiddenFrom.includes(viewer) ? { ...h, left: '-', right: '-' } : h - ); + return state.wizards[subject].history.map((h) => (hiddenFromViewer(h.hiddenFrom, 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); } diff --git a/src/lib/game/talk.ts b/src/lib/game/talk.ts new file mode 100644 index 0000000..545f023 --- /dev/null +++ b/src/lib/game/talk.ts @@ -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 { + try { + return JSON.parse(localStorage.getItem(SEEN_KEY) ?? '{}') as Record; + } 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)); +} diff --git a/src/lib/game/view.test.ts b/src/lib/game/view.test.ts new file mode 100644 index 0000000..4c50300 --- /dev/null +++ b/src/lib/game/view.test.ts @@ -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.']); + }); +}); diff --git a/src/lib/game/view.ts b/src/lib/game/view.ts index bf05173..28b873f 100644 --- a/src/lib/game/view.ts +++ b/src/lib/game/view.ts @@ -3,7 +3,7 @@ // describe those gestures are withheld too. Spells cast are always visible, as // 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 { const view = structuredClone(state); @@ -11,16 +11,24 @@ export function viewFor(state: GameState, viewer: WizardId): GameState { if (id === viewer) continue; 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; } -/** 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 { roomId: string; seq: number; /** The most seats the table will take. */ size: number; + /** The viewer's seat, or SPECTATOR for the gallery. */ me: WizardId; /** The wizard who opened the table and may begin the duel. */ host: WizardId; @@ -28,4 +36,7 @@ export interface RoomView { /** Seats that still have to move before the turn resolves. */ waitingOn: WizardId[]; state: GameState | null; + chat: ChatLine[]; + /** How many watch from the Peanut Gallery. */ + audience: number; } diff --git a/src/routes/guide/+page.svelte b/src/routes/guide/+page.svelte index 1782c1f..9fa1bbc 100644 --- a/src/routes/guide/+page.svelte +++ b/src/routes/guide/+page.svelte @@ -16,6 +16,7 @@ The reveal Health, creatures and the sheet On a phone + The Peanut Gallery and table talk A first duel @@ -97,6 +98,12 @@

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 More spells opens the rest.

+ +

A first duel

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.

diff --git a/src/routes/join/[code]/+page.svelte b/src/routes/join/[code]/+page.svelte index 90238d2..00db125 100644 --- a/src/routes/join/[code]/+page.svelte +++ b/src/routes/join/[code]/+page.svelte @@ -1,6 +1,7 @@