From 5c79a65319a2b050c6ab347914b0f88e9110d541 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Wed, 23 Sep 2026 11:21:13 -0400 Subject: [PATCH] A crowded table: keyed threats, a compact roster, a ledger selector, host-only starts Threats are keyed by seat as well as hand and spell, so two opponents building the same spell no longer crash the board (regression test). Past three wizards the rail lists the others on a line each with health, condition and whether they have moved; a row of names turns the ledger toward any wizard, as do pressing a warning or choosing a target; each seat keeps one colour across the ledger, the warnings and the rail. The result strip folds untouched wizards into one line. A full table no longer begins by itself: the host begins at every size and may send a bot away. Target lists carry health and condition, and an intent sentence says what the reveal will do. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up --- deploy/replay-ledgers.ts | 1 + deploy/visitors.sh | 2 + deploy/waving-hands-rollup.sh | 1 + server/src/index.ts | 7 ++ server/src/rooms.ts | 19 +++- server/src/store.ts | 2 + src/lib/components/Board.svelte | 48 ++++++-- src/lib/components/HandPicker.svelte | 2 +- src/lib/components/Ledger.svelte | 73 ++++++++++++- src/lib/components/Roster.svelte | 145 +++++++++++++++++++++++++ src/lib/components/TurnSummary.svelte | 25 +++-- src/lib/components/WizardStatus.svelte | 27 +---- src/lib/game/client.ts | 1 + src/lib/game/colors.ts | 12 ++ src/lib/game/duel.svelte.ts | 65 ++++++++++- src/lib/game/duel.test.ts | 24 +++- src/lib/game/status.ts | 54 +++++++++ src/routes/guide/+page.svelte | 6 +- src/routes/join/[code]/+page.svelte | 19 +++- 19 files changed, 473 insertions(+), 60 deletions(-) create mode 100644 src/lib/components/Roster.svelte create mode 100644 src/lib/game/colors.ts create mode 100644 src/lib/game/status.ts diff --git a/deploy/replay-ledgers.ts b/deploy/replay-ledgers.ts index aa53731..ba4cbd7 100644 --- a/deploy/replay-ledgers.ts +++ b/deploy/replay-ledgers.ts @@ -38,6 +38,7 @@ async function main(): Promise { try { for (const line of lines) { if (line.t === 'seat') seats.push(line); + else if (line.t === 'unseat') seats.splice(seats.findIndex((s) => s.id === line.id), 1); else if (line.t === 'start') state = createGame(Object.fromEntries(seats.map((s) => [s.id, s.name])), line.seed, line.rules ?? 1); else if (line.t === 'turn' && state) { state = resolveTurn(state, line.inputs as Record); diff --git a/deploy/visitors.sh b/deploy/visitors.sh index ff6144a..cdddbfb 100755 --- a/deploy/visitors.sh +++ b/deploy/visitors.sh @@ -87,6 +87,8 @@ for f in glob.glob("/var/lib/waving-hands/rooms/*.jsonl"): if x["t"] == "seat": (bots_seated if x["bot"] else humans).append(x["name"]) names[x["id"]] = x["name"] + elif x["t"] == "unseat": + bots_seated.remove(names.pop(x["id"], None)) if x["id"] in names else None elif x["t"] == "start": started = True elif x["t"] == "turn": diff --git a/deploy/waving-hands-rollup.sh b/deploy/waving-hands-rollup.sh index 31e9035..9790623 100755 --- a/deploy/waving-hands-rollup.sh +++ b/deploy/waving-hands-rollup.sh @@ -79,6 +79,7 @@ for f in glob.glob("/var/lib/waving-hands/rooms/*.jsonl"): if x["t"] == "seat": if x["bot"]: bots += 1 else: humans.add(x["name"]) + elif x["t"] == "unseat": bots -= 1 day_turns = [x for x in lines if x["t"] == "turn" and t0 <= x["at"] / 1000 < t1] turns += len(day_turns) # A duel finished today: its last turn is today's and the engine would say so; approximate by replay-free means: diff --git a/server/src/index.ts b/server/src/index.ts index 6f4ade6..152347a 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -5,6 +5,7 @@ // 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 +// POST /api/rooms/:id/unseat {token, seat} the host sends a bot away before the duel begins // GET /api/rooms/:id?token= the view for that seat; without a token, the gallery's view // POST /api/rooms/:id/turn {token, input} this seat's move // POST /api/rooms/:id/say {token, text} table talk, from a seat to the whole room @@ -205,6 +206,12 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise const seat = rooms.seatOf(room, body.token as string); return send(res, 200, rooms.view(room, seat.id)); } + if (action === 'unseat') { + const token = typeof body.token === 'string' ? body.token : undefined; + rooms.unseat(room, token, body.seat); + const seat = rooms.seatOf(room, token); + return send(res, 200, rooms.view(room, seat.id)); + } if (action === 'begin') { const token = typeof body.token === 'string' ? body.token : undefined; rooms.begin(room, token); diff --git a/server/src/rooms.ts b/server/src/rooms.ts index 67f6b8e..ce812d8 100644 --- a/server/src/rooms.ts +++ b/server/src/rooms.ts @@ -136,7 +136,7 @@ export class Rooms { return this.sit(room, name, false); } - /** The host, who opened the table, begins the duel once at least two are seated. A full table begins by itself. */ + /** The host, who opened the table, begins the duel once at least two are seated, however full the table. */ begin(room: Room, token: string | undefined): void { const seat = this.seatOf(room, token); if (seat.id !== room.seats[0]?.id) throw new RoomError('Only the wizard who opened the table may begin the duel.', 403); @@ -158,12 +158,22 @@ export class Rooms { if (room.seats.length >= room.size) throw new RoomError('Every seat at this duel is taken.', 409); const name = cleanName(rawName); if (room.seats.some((s) => s.name.toLowerCase() === name.toLowerCase())) throw new RoomError('Another wizard here already has that name.', 409); - const seat: Seat = { id: SEAT_IDS[room.seats.length], name, token: bot ? '' : newId(18), bot }; + const id = SEAT_IDS.find((s) => !room.seats.some((taken) => taken.id === s))!; + const seat: Seat = { id, name, token: bot ? '' : newId(18), bot }; this.commit(room, { t: 'seat', id: seat.id, name, token: seat.token, bot }); - if (room.seats.length === room.size) this.commit(room, { t: 'start', seed: newSeed(), rules: CURRENT_RULES }); return seat; } + /** The host sends a bot away before the duel begins. People leave by not coming back. */ + unseat(room: Room, token: string | undefined, seatId: unknown): void { + const host = this.seatOf(room, token); + if (host.id !== room.seats[0]?.id) throw new RoomError('Only the wizard who opened the table may send a bot away.', 403); + if (room.state) throw new RoomError('The duel has begun; the table is set.', 409); + const seat = room.seats.find((s) => s.id === seatId); + if (!seat || !seat.bot) throw new RoomError('Only a bot can be sent away.', 400); + this.commit(room, { t: 'unseat', id: seat.id }); + } + /** Record a seat's move; resolve the turn once nobody else is awaited. */ submit(room: Room, token: string | undefined, raw: unknown): void { const seat = this.seatOf(room, token); @@ -278,6 +288,9 @@ function apply(room: Room, line: LedgerLine): void { case 'seat': room.seats.push({ id: line.id, name: line.name, token: line.token, bot: line.bot }); break; + case 'unseat': + room.seats = room.seats.filter((s) => s.id !== line.id); + break; case 'start': room.state = createGame(Object.fromEntries(room.seats.map((s) => [s.id, s.name])), line.seed, line.rules ?? 1); break; diff --git a/server/src/store.ts b/server/src/store.ts index 3cd9608..63489a2 100644 --- a/server/src/store.ts +++ b/server/src/store.ts @@ -9,6 +9,8 @@ import type { TurnInput, WizardId } from '../../src/lib/game/state'; export type LedgerLine = | { t: 'room'; id: string; seats: number; createdAt: number } | { t: 'seat'; id: WizardId; name: string; token: string; bot: boolean } + /** A bot sent away by the host before the duel began; its seat id is free again. */ + | { t: 'unseat'; id: WizardId } /** Ledgers from before revisions were recorded resolve under rules 1. */ | { t: 'start'; seed: number; rules?: number } | { t: 'turn'; at: number; inputs: Record } diff --git a/src/lib/components/Board.svelte b/src/lib/components/Board.svelte index 7a045f1..96b1106 100644 --- a/src/lib/components/Board.svelte +++ b/src/lib/components/Board.svelte @@ -3,11 +3,13 @@ import HandPicker from './HandPicker.svelte'; import Ledger from './Ledger.svelte'; import MobileBar from './MobileBar.svelte'; + import Roster from './Roster.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'; + import { seatColor } from '$lib/game/colors'; import { GESTURE_SHORT, glyph } from '$lib/game/glyphs'; import { MAGIC_GESTURES } from '$lib/game/spells'; @@ -16,6 +18,8 @@ const foeMonsters = $derived(duel.state.monsters.filter((m) => m.owner !== duel.me)); const outcome = $derived(duel.state.over); const won = $derived(outcome?.winner === duel.me); + /** Past three wizards the rail lists the others on a line each. */ + const crowded = $derived(duel.others.length >= 3);
@@ -58,11 +62,11 @@ {#if duel.threatsNow.length}

Their hands could finish this turn:

    - {#each duel.threatsNow as t (t.hand + t.spell.id)} + {#each duel.threatsNow as t (duel.threatKey(t))} {@const active = duel.isHighlighted(t)}
  • @@ -74,11 +78,11 @@
    Two gestures away ({duel.threatsNext.length})
      - {#each duel.threatsNext as t (t.hand + t.spell.id)} + {#each duel.threatsNext as t (duel.threatKey(t))} {@const active = duel.isHighlighted(t)}
    • @@ -118,9 +122,9 @@ {#if duel.stabbing}