A table for a dozen wizards, begun by its host

Rooms take up to twelve seats and the host begins the duel with whoever
is seated; a full table begins by itself. The lobby is a roster with a
bot seat and the host's begin button. The ledger pins the turn and your
own columns and scrolls sideways to the rest; the phone bar shows every
wizard's health; a wizard who is out watches the rest; each charmed
wizard gets their own gesture; an unnamed spell goes at whoever hurt you
last, else the weakest opponent; a stab has a target from the start.

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:34:21 -04:00
co-authored by Claude Fable 5.1
parent 79947e31ba
commit 58d7db106e
15 changed files with 299 additions and 61 deletions
+21 -4
View File
@@ -11,8 +11,9 @@ import { createGame, inDuel, type GameState, type TurnInput, type WizardId } fro
import { viewFor, type RoomView } from '../../src/lib/game/view';
import type { LedgerLine, Store } from './store';
const SEAT_IDS: WizardId[] = ['A', 'B', 'C', 'D'];
const BOT_NAMES = ['Aldric', 'Morwenna', 'Thessaly', 'Gandric', 'Ysolde', 'Ormund', 'Corwin', 'Isaura'];
/** 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 interface Seat {
@@ -55,6 +56,10 @@ function newCode(): string {
return [...bytes].map((b) => CODE_ALPHABET[b % CODE_ALPHABET.length]).join('');
}
function newSeed(): number {
return (Date.now() ^ (Math.random() * 0xffffffff)) >>> 0;
}
export function normalizeCode(raw: string): string {
return raw.trim().toUpperCase();
}
@@ -109,7 +114,7 @@ export class Rooms {
}
create(name: string, size: number): { room: Room; seat: Seat } {
if (!Number.isInteger(size) || size < 2 || size > MAX_SEATS) throw new RoomError(`A duel 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();
while (this.rooms.has(id)) id = newCode();
const room: Room = { id, size, createdAt: Date.now(), seats: [], state: null, pending: {}, seq: 0, updatedAt: Date.now() };
@@ -120,11 +125,22 @@ export class Rooms {
}
join(room: Room, name: string): Seat {
if (room.state) throw new RoomError('The duel has begun; no more seats are taken.', 409);
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. */
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);
if (room.state) throw new RoomError('The duel has already begun.', 409);
if (room.seats.length < 2) throw new RoomError('A duel needs at least two wizards.', 409);
this.commit(room, { t: 'start', seed: newSeed() });
}
addBot(room: Room, token: string | undefined): Seat {
this.seatOf(room, token);
if (room.state) throw new RoomError('The duel has begun; no more seats are taken.', 409);
const taken = new Set(room.seats.map((s) => s.name.toLowerCase()));
const pool = BOT_NAMES.filter((n) => !taken.has(n.toLowerCase()));
const name = pool[Math.floor(Math.random() * pool.length)] ?? 'The Construct';
@@ -137,7 +153,7 @@ export class Rooms {
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 };
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: (Date.now() ^ (Math.random() * 0xffffffff)) >>> 0 });
if (room.seats.length === room.size) this.commit(room, { t: 'start', seed: newSeed() });
return seat;
}
@@ -175,6 +191,7 @@ export class Rooms {
seq: room.seq,
size: room.size,
me: seatId,
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