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
+8 -1
View File
@@ -3,7 +3,8 @@
//
// POST /api/rooms {name, size?} create a duel and take seat A
// POST /api/rooms/:id/join {name} take the next seat
// POST /api/rooms/:id/bot {token} seat a bot (any seated player may)
// 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
// POST /api/rooms/:id/turn {token, input} this seat's move
// WS /ws?room=:id&token= {type:"update", seq} whenever the room changes
@@ -109,6 +110,12 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
const seat = rooms.seatOf(room, body.token as string);
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);
const seat = rooms.seatOf(room, token);
return send(res, 200, rooms.view(room, seat.id));
}
if (action === 'turn') {
const token = typeof body.token === 'string' ? body.token : undefined;
rooms.submit(room, token, body.input);
+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
+13 -8
View File
@@ -6,7 +6,7 @@
import SpellSheet from './SpellSheet.svelte';
import TurnSummary from './TurnSummary.svelte';
import WizardStatus from './WizardStatus.svelte';
import { castKey, tokensText, type Duel } from '$lib/game/duel.svelte';
import { castKey, listText, tokensText, type Duel } from '$lib/game/duel.svelte';
import { GESTURE_SHORT, glyph } from '$lib/game/glyphs';
import { MAGIC_GESTURES } from '$lib/game/spells';
@@ -15,8 +15,6 @@
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);
const charmedName = $derived(duel.charmedFoe ? duel.state.wizards[duel.charmedFoe].name : '');
const charmedHandName = $derived(duel.charmedFoe ? duel.state.wizards[duel.charmedFoe].constraints.charmed?.hand : '');
</script>
<main class="board">
@@ -35,6 +33,12 @@
{/if}
</div>
{:else}
{#if duel.out}
<section class="move out" aria-labelledby="move-heading">
<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>
</section>
{:else}
<section class="move" aria-labelledby="move-heading">
{#if duel.timeStopped}
<h2 id="move-heading">Time stands still. Take your extra turn.</h2>
@@ -162,23 +166,23 @@
</select>
</label>
{/if}
{#if duel.youCharmedFoe}
{#each duel.charmedFoes as id (id)}
<label class="field">
<span>{charmedName}'s charmed {charmedHandName} hand makes</span>
<select bind:value={duel.charmGesture}>
<span>{duel.state.wizards[id].name}'s charmed {duel.state.wizards[id].constraints.charmed?.hand} hand makes</span>
<select bind:value={duel.charmGestures[id]}>
{#each MAGIC_GESTURES as g (g)}
<option value={g}>{glyph(g)} {GESTURE_SHORT[g]}</option>
{/each}
</select>
</label>
{/if}
{/each}
</div>
{#if duel.remote?.error}
<p class="warning">{duel.remote.error}</p>
{/if}
{#if duel.moved}
<p class="waiting">Your move is in. Waiting for {duel.awaiting.join(' and ')}.</p>
<p class="waiting">Your move is in. Waiting for {duel.awaitingText}.</p>
{/if}
{#if duel.surrendering}
<p class="warning">Both palms at once is surrender. You will lose the duel.</p>
@@ -191,6 +195,7 @@
{duel.moved ? 'Waiting' : duel.sending ? 'Sending' : duel.surrendering ? 'Surrender' : duel.timeStopped ? 'Act in the stopped moment' : 'Reveal gestures'}
</button>
</section>
{/if}
{/if}
</section>
+3 -3
View File
@@ -55,7 +55,7 @@
error = '';
try {
rememberName(name);
const remote = await Duel.createRoom(name.trim(), 2);
const remote = await Duel.createRoom(name.trim());
await goto(`/join/${remote.roomId}`);
} catch (e) {
error = e instanceof Error ? e.message : 'The duel could not be opened.';
@@ -110,7 +110,7 @@
<div class="lid-head">
<h1>Waving Hands</h1>
<p class="pitch">Two 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 both wizards choose a gesture per hand and reveal them together; a Fireball is F-S-S-D-D on one hand, a Shield is a single palm. Play the bot, or a friend 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>
<a class="about-link" href="#about" onclick={() => (aboutOpen = true)}>about this game — a labor of love</a>
</div>
@@ -128,7 +128,7 @@
{/if}
</p>
<div class="ways">
<button type="button" class="quiet" disabled={!ready || busy} onclick={create}>Create a duel</button>
<button type="button" class="quiet" disabled={!ready || busy} onclick={create}>Open a table</button>
<form class="joinform" onsubmit={join}>
<span class="or">or join one</span>
<input type="text" class="code" bind:value={code} maxlength="4" placeholder="CODE" autocapitalize="characters" autocomplete="off" aria-label="room code" />
+53 -8
View File
@@ -79,14 +79,14 @@
<tr>
<th class="turn" scope="col"><span class="visually-hidden">Turn</span></th>
{#each columns as who (who)}
<th class:you={who === duel.me} class:foe={who !== duel.me} colspan="2" scope="colgroup">{duel.state.wizards[who].name}{#if who === duel.me}&nbsp;(you){/if}</th>
<th class:you={who === duel.me} class:foe={who !== duel.me} class:gone={!duel.state.wizards[who].alive || duel.state.wizards[who].surrendered} colspan="2" scope="colgroup">{duel.state.wizards[who].name}{#if who === duel.me}&nbsp;(you){/if}</th>
{/each}
</tr>
<tr class="hands">
<th></th>
{#each columns as who (who)}
<th scope="col">left</th>
<th scope="col">right</th>
<th scope="col" class:pin-left={who === duel.me}>left</th>
<th scope="col" class:pin-right={who === duel.me}>right</th>
{/each}
</tr>
</thead>
@@ -96,7 +96,7 @@
<td class="turn">{row.label}{#if row.note}<span class="rownote">{row.note}</span>{/if}</td>
{#each columns as who (who)}
{#each ['left', 'right'] as const as hand (hand)}
<td class="cell" class:you={who === duel.me} class:foe={who !== duel.me}>
<td class="cell" class:you={who === duel.me} class:foe={who !== duel.me} class:pin-left={who === duel.me && hand === 'left'} class:pin-right={who === duel.me && hand === 'right'}>
{#each row.cells[who] as p (p.index)}
{@const notes = notesFor(who, p.index, hand)}
{@const other = hand === 'left' ? p.entry.right : p.entry.left}
@@ -114,7 +114,7 @@
<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>
{#each ['left', 'right'] as const as hand (hand)}
<td class="cell you">
<td class="cell you" class:pin-left={hand === 'left'} class:pin-right={hand === 'right'}>
{#each duel.activeSets as set (set)}
{@const undecided = duel.chosenGesture(set, hand) === null || duel.charmedHand === hand}
{@const cast = duel.chosen[set][hand]}
@@ -138,17 +138,53 @@
<style>
.scroller {
max-height: clamp(170px, 30vh, 400px);
overflow-y: auto;
overflow: auto;
border-top: 1px solid var(--rule-strong);
border-bottom: 1px solid var(--rule-strong);
}
/* With many wizards the table grows sideways; the turn and your own two columns stay pinned on the left. */
.ledger {
width: 100%;
border-collapse: collapse;
min-width: 100%;
border-collapse: separate;
border-spacing: 0;
table-layout: fixed;
}
.ledger th,
.ledger td {
min-width: 4.5rem;
}
th.pin-left,
td.pin-left,
th.pin-right,
td.pin-right {
position: sticky;
z-index: 1;
background: var(--slate-deep);
}
th.pin-left,
td.pin-left {
left: 2.9rem;
}
th.pin-right,
td.pin-right {
left: 7.4rem;
}
thead th.pin-left,
thead th.pin-right {
z-index: 3;
}
thead th.gone {
text-decoration: line-through;
color: var(--bone-dim);
}
thead th {
position: sticky;
top: 0;
@@ -177,7 +213,12 @@
th.turn,
td.turn {
position: sticky;
left: 0;
z-index: 2;
background: var(--slate-deep);
width: 2.9rem;
min-width: 2.9rem;
color: var(--bone-faint);
text-align: right;
padding-right: 0.5rem;
@@ -266,4 +307,8 @@
border-bottom: 2px solid var(--ember);
}
tr.draft td.turn {
background: var(--slate);
}
</style>
+18 -3
View File
@@ -8,9 +8,12 @@
{#if !duel.state.over}
<div class="bar">
<div class="health">
<div class="health" class:many={duel.others.length > 1}>
<span><em>{duel.you.name}</em> {duel.you.hp}/{MAX_HP}</span>
<span><em>{duel.foe.name}</em> {duel.foe.hp}/{MAX_HP}</span>
{#each duel.others as id (id)}
{@const w = duel.state.wizards[id]}
<span class:gone={!w.alive || w.surrendered}><em>{w.name}</em> {w.hp}/{MAX_HP}</span>
{/each}
</div>
<div class="gestures" aria-label="Chosen gestures">
{#each HANDS as hand (hand)}
@@ -18,7 +21,7 @@
{/each}
</div>
<button type="button" class="reveal" disabled={!duel.ready} onclick={() => duel.reveal()}>
{duel.surrendering ? 'Surrender' : duel.timeStopped ? 'Act' : 'Reveal'}
{duel.out ? 'Out' : duel.moved ? 'Waiting' : duel.surrendering ? 'Surrender' : duel.timeStopped ? 'Act' : 'Reveal'}
</button>
</div>
{/if}
@@ -55,10 +58,22 @@
line-height: 1.3;
}
.health.many {
flex-direction: row;
flex-wrap: wrap;
gap: 0 0.6rem;
font-size: 0.72rem;
}
.health em {
color: var(--bone);
}
.health .gone {
text-decoration: line-through;
opacity: 0.6;
}
.gestures {
display: flex;
gap: 0.5rem;
+1 -1
View File
@@ -42,7 +42,7 @@
onclick={() => duel.follow(hand, plan)}
>
<span class="which">Plan with {hand} hand</span>
<span class="rest">{#if plan.done.length}<span class="done">{tokensText(plan.done)}</span> then {/if}{tokensText(plan.remaining)} <span class="count">({plan.remaining.length} more)</span></span>
<span class="rest">{#if plan.done.length}<span class="done">{tokensText(plan.done)}</span>&nbsp;then&nbsp;{/if}{tokensText(plan.remaining)} <span class="count">({plan.remaining.length} more)</span></span>
</button>
{/each}
</div>
+8 -5
View File
@@ -356,14 +356,16 @@ function toChoice(s: Situation, hand: Hand, completion: Completion): CastChoice
return choice;
}
/** The gesture that most disrupts a charmed hand's sequences. */
function charmGesture(s: Situation): Gesture {
const foeW = s.state.wizards[s.foe];
/** The gesture that most disrupts a charmed wizard's hand. */
function charmGesture(s: Situation, who: WizardId): Gesture {
const foeW = s.state.wizards[who];
const charmedHand = foeW.constraints.charmed?.hand ?? 'left';
const hist = visibleHistory(s.state, s.me, who);
const theirs = handSequence(hist, charmedHand);
let best: Gesture = 'P';
let bestScore = Infinity;
for (const g of MAGIC_GESTURES) {
const seq = [...s.foeSeq[charmedHand], { own: g, other: '-' as Gesture }];
const seq = [...theirs, { own: g, other: '-' as Gesture }];
let score = 0;
for (const spell of SPELLS) {
if (!HOSTILE_SPELLS.includes(spell.id)) continue;
@@ -453,6 +455,7 @@ export function chooseBotTurn(state: GameState, me: WizardId, rng: () => number
input.release = { target: choice.target, monsterTarget: choice.monsterTarget };
}
}
if (state.seats.some((id) => state.wizards[id].constraints.charmed?.by === me)) input.charmGesture = charmGesture(s);
const charmed = state.seats.filter((id) => state.wizards[id].constraints.charmed?.by === me);
if (charmed.length) input.charmGestures = Object.fromEntries(charmed.map((id) => [id, charmGesture(s, id)]));
return input;
}
+1
View File
@@ -72,6 +72,7 @@ export const api = {
create: (name: string, size: number) => call<Seated>('POST', '/api/rooms', { name, size }),
join: (roomId: string, name: string) => call<Seated>('POST', `/api/rooms/${roomId}/join`, { name }),
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 }),
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 })
};
+42 -8
View File
@@ -35,6 +35,7 @@ import {
NOWHERE,
createGame,
enemyOf,
inDuel,
visibleHistory,
type CastChoice,
type GameState,
@@ -139,6 +140,12 @@ export interface Highlight {
to: number;
}
/** Names as a sentence lists them. */
export function listText(names: string[]): string {
if (names.length <= 1) return names.join('');
return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;
}
/** A token in Bartle's notation: a bracketed lower-case letter is a two-handed gesture. */
export function tokenText(t: Token): string {
return t.both && t.gesture !== 'C' ? `(${t.gesture.toLowerCase()}` : t.gesture;
@@ -201,6 +208,7 @@ function readPreference(key: string, fallback: boolean): boolean {
export interface Remote extends HeldSeat {
roomId: string;
size: number;
host: WizardId;
seats: RoomView['seats'];
waitingOn: WizardId[];
started: boolean;
@@ -218,7 +226,8 @@ export class Duel {
monsterOrders = $state<Record<string, TargetId>>({});
/** Narrow screens get shorter lists and a sticky bar; the page sets this from a media query. */
compact = $state(false);
charmGesture = $state<Gesture>('P');
/** The gesture each wizard you charmed will make, by their seat. */
charmGestures = $state<Record<WizardId, Gesture>>({});
/** Where to loose the banked spell this turn; empty keeps it. */
release = $state<TargetId | ''>('');
/** Which cast to bank or make permanent when more than one qualifies. */
@@ -261,6 +270,8 @@ export class Duel {
*/
syncAll(): void {
for (const s of SET_NAMES) for (const h of HANDS) this.syncSpell(s, h);
// A knife needs someone to stab: the same wizard a spell would go at.
if (this.stabbing && !this.stabTarget) this.stabTarget = this.foeId;
}
/** Rename the player for this duel and every duel after it. An empty name goes back to a drawn one. */
@@ -317,6 +328,8 @@ export class Duel {
awaiting = $derived(
this.remote ? this.remote.waitingOn.filter((id) => id !== this.me).map((id) => this.state.wizards[id]?.name ?? id) : []
);
/** "Ysolde", "Ysolde and Grey", "Ysolde, Grey and Ormund". */
awaitingText = $derived(listText(this.awaiting));
hasted = $derived(this.you.hasteTurns > 0 && !this.timeStopped);
activeSets = $derived<SetName[]>(this.hasted ? ['main', 'haste'] : ['main']);
@@ -336,9 +349,10 @@ export class Duel {
return set === 'haste' ? this.forcedSecond[hand] : this.forced[hand];
}
charmedHand = $derived<Hand | undefined>(this.timeStopped ? undefined : this.you.constraints.charmed?.hand);
/** The opponent whose hand is yours to command this turn, if any. */
charmedFoe = $derived(this.others.find((id) => this.state.wizards[id].constraints.charmed?.by === this.me));
youCharmedFoe = $derived(this.charmedFoe !== undefined && !this.timeStopped);
/** The wizards whose hands are yours to command this turn. */
charmedFoes = $derived(this.timeStopped ? [] : this.others.filter((id) => this.state.wizards[id].constraints.charmed?.by === this.me));
/** Dead or surrendered: the duel goes on without you, and you may watch. */
out = $derived(!inDuel(this.you));
/** The gesture a hand will make: forced by an enchantment, or chosen, or nothing yet. */
chosenGesture(set: SetName, hand: Hand): Gesture | null {
@@ -566,6 +580,7 @@ export class Duel {
yourMonsters = $derived(this.state.monsters.filter((m) => m.owner === this.me));
ready = $derived(
!this.state.over &&
!this.out &&
!this.moved &&
!this.sending &&
this.activeSets.every((set) =>
@@ -671,7 +686,9 @@ export class Duel {
};
}
if (this.release && this.you.banked) yours.release = { target: this.release, monsterTarget: this.foeId };
if (this.youCharmedFoe) yours.charmGesture = this.charmGesture;
if (this.charmedFoes.length) {
yours.charmGestures = Object.fromEntries(this.charmedFoes.map((id) => [id, this.charmGestures[id] ?? 'P']));
}
return yours;
}
@@ -711,6 +728,7 @@ export class Duel {
const r = this.remote!;
r.seats = view.seats;
r.size = view.size;
r.host = view.host;
r.waitingOn = view.waitingOn;
const wasStarted = r.started;
r.started = view.state !== null;
@@ -730,7 +748,21 @@ export class Duel {
return watchRoom(r.roomId, r.token, refresh, (open) => (r.connected = open));
}
/** Seat the bot in the empty chair. */
/** You opened this table, so the duel begins when you say. */
isHost = $derived(!!this.remote && this.remote.host === this.remote.seat);
/** Begin the duel with whoever is seated. */
async begin(): Promise<void> {
const r = this.remote;
if (!r) return;
try {
this.apply(await api.begin(r.roomId, r.token));
} catch (e) {
r.error = e instanceof Error ? e.message : 'The duel could not begin.';
}
}
/** Seat the bot in an empty chair. */
async addBot(): Promise<void> {
const r = this.remote;
if (!r) return;
@@ -777,6 +809,7 @@ export class Duel {
this.release = '';
this.bankPick = '';
this.permanentPick = '';
this.charmGestures = {};
this.highlight = null;
}
@@ -787,8 +820,8 @@ export class Duel {
this.resetDrafts();
}
/** Open a duel for another person on the server, seated as the first wizard. */
static async createRoom(name: string, size = 2): Promise<Remote> {
/** Open a table on the server, seated as its host; it takes up to a dozen and begins when the host says. */
static async createRoom(name: string, size = 12): Promise<Remote> {
const seated = await api.create(name, size);
rememberSeat(seated.view.roomId, { seat: seated.seat, token: seated.token });
return remoteFrom(seated.view, seated.token);
@@ -819,6 +852,7 @@ function remoteFrom(view: RoomView, token: string): Remote {
seat: view.me,
token,
size: view.size,
host: view.host,
seats: view.seats,
waitingOn: view.waitingOn,
started: view.state !== null,
+39 -1
View File
@@ -3,7 +3,7 @@ import { chooseBotTurn } from './bot';
import { completedSpells, handSequence, castsConflict, completableIn, progressToward } from './gestures';
import { resolveTurn } from './resolve';
import { SPELLS, SPELL_BY_ID, parseSequence, type Gesture } from './spells';
import { createGame } from './state';
import { createGame, enemyOf } from './state';
import { lcg, noop, play, runSequence } from './test-helpers';
describe('spell table', () => {
@@ -446,3 +446,41 @@ describe('more than two wizards', () => {
expect(s.wizards.A.history.at(-1)?.hiddenFrom).toEqual(['B', 'C']);
});
});
describe('at a table of several wizards', () => {
it('an unnamed spell goes at whoever hurt you last, else the weakest opponent', () => {
let s = createGame({ A: 'Black', B: 'White', C: 'Grey' }, 1);
// Nobody has hurt anyone: the weakest opponent is the default; all equal, so table order.
expect(enemyOf(s, 'A')).toBe('B');
// Grey stabs Black; Black's next unnamed spell goes back at Grey.
s = resolveTurn(s, { A: noop(), B: noop(), C: { ...noop(), left: '>', stabTarget: 'A' } });
expect(s.wizards.A.lastHurtBy).toBe('C');
expect(enemyOf(s, 'A')).toBe('C');
s = runSequence(s, 'A', 'SD', '--', { casts: [{ hand: 'left', spellId: 'missile' }] });
expect(s.wizards.C.hp).toBe(14);
// With Grey gone, the grudge lapses to the weakest opponent standing.
s.wizards.C.alive = false;
expect(enemyOf(s, 'A')).toBe('B');
});
it('a wizard who charms two others names a gesture for each', () => {
let s = createGame({ A: 'Black', B: 'White', C: 'Grey' }, 1);
// Both palms at once would be a surrender, so the two charms finish a turn apart.
const left = 'PSDF-';
const right = '-PSDF';
for (let i = 0; i < 5; i++) {
const casts = [];
if (i === 3) casts.push({ hand: 'left' as const, spellId: 'charm_person' as const, target: 'B', chosenHand: 'left' as const });
if (i === 4) casts.push({ hand: 'right' as const, spellId: 'charm_person' as const, target: 'C', chosenHand: 'left' as const });
s = resolveTurn(s, {
A: { ...noop(), left: left[i] as Gesture, right: right[i] as Gesture, casts, charmGestures: i === 4 ? { B: 'W' } : undefined },
B: { ...noop(), left: 'D' },
C: { ...noop(), left: 'D' }
});
}
expect(s.wizards.B.history.at(-1)?.left).toBe('W');
s = resolveTurn(s, { A: { ...noop(), charmGestures: { C: 'F' } }, B: { ...noop(), left: 'D' }, C: { ...noop(), left: 'D' } });
expect(s.wizards.C.history.at(-1)?.left).toBe('F');
expect(s.wizards.B.history.at(-1)?.left).toBe('D');
});
});
+15 -11
View File
@@ -214,7 +214,8 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
return [prev.left, prev.right];
}
if (c.charmed) {
const g = c.charmedGesture ?? input(c.charmed.by).charmGesture ?? '-';
const charmer = input(c.charmed.by);
const g = c.charmedGesture ?? charmer.charmGestures?.[w.id] ?? charmer.charmGesture ?? '-';
if (w.permanent.charmed && !w.permanent.charmedGesture) w.permanent.charmedGesture = g;
if (c.charmed.hand === 'left') left = g;
else right = g;
@@ -495,7 +496,8 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
};
state.monsters.push(m);
orders.set(m.id, c.choice.monsterTarget ?? enemyOf(state, controller));
log(`A ${stats.name.toLowerCase()} answers ${W[controller].name}'s summons.`, 'spell');
const creature = stats.name.toLowerCase();
log(`${/^[aeiou]/.test(creature) ? 'An' : 'A'} ${creature} answers ${W[controller].name}'s summons.`, 'spell');
}
let fireStorm = live().some((c) => c.spell.id === 'fire_storm');
@@ -727,8 +729,9 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
const damage = new Map<TargetId, number>();
const healing = new Map<TargetId, number>();
const slain = new Set<TargetId>();
const hurt = (id: TargetId, n: number, why: string) => {
const hurt = (id: TargetId, n: number, why: string, by?: WizardId) => {
damage.set(id, (damage.get(id) ?? 0) + n);
if (isWizard(id) && by && by !== id) W[id].lastHurtBy = by;
note(id, tidy(why), -n);
log(`${beingName(state, id)} takes ${n} from ${why}.`, 'damage');
};
@@ -743,31 +746,32 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
if (isShielded(t)) {
facts.shieldBlocked.add(t);
log(`${guard(t)} turns the missile aside.`, 'fizzle');
} else hurt(t, 1, 'a missile');
} else hurt(t, 1, 'a missile', c.caster);
break;
case 'finger_of_death':
slain.add(t);
if (isWizard(t)) W[t].lastHurtBy = c.caster;
note(t, 'Finger of death', -(isWizard(t) ? W[t].hp : 0));
log(`The finger of death touches ${beingName(state, t)}.`, 'death');
break;
case 'lightning_bolt':
case 'lightning_bolt_quick':
if (isShielded(t)) facts.throughShield.push({ target: t, spell: 'a lightning bolt' });
hurt(t, 5, 'a lightning bolt');
hurt(t, 5, 'a lightning bolt', c.caster);
break;
case 'cause_light_wounds':
if (isShielded(t)) facts.throughShield.push({ target: t, spell: 'cause light wounds' });
hurt(t, 2, 'cause light wounds');
hurt(t, 2, 'cause light wounds', c.caster);
break;
case 'cause_heavy_wounds':
if (isShielded(t)) facts.throughShield.push({ target: t, spell: 'cause heavy wounds' });
hurt(t, 3, 'cause heavy wounds');
hurt(t, 3, 'cause heavy wounds', c.caster);
break;
case 'fireball':
if (resists(t, 'heat')) log(`${beingName(state, t)} shrugs off the fireball.`, 'fizzle');
else {
if (isShielded(t)) facts.throughShield.push({ target: t, spell: 'a fireball' });
hurt(t, 5, 'a fireball');
hurt(t, 5, 'a fireball', c.caster);
}
break;
case 'cure_light_wounds':
@@ -825,7 +829,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
if (isShielded(id)) continue;
if (isWizard(id) && W[id].invisibleTurns > 0) continue;
}
hurt(id, m.attack, `the ${m.name.toLowerCase()}`);
hurt(id, m.attack, `the ${m.name.toLowerCase()}`, m.owner);
}
continue;
}
@@ -842,7 +846,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
log(`${m.name} flails at the air where ${W[target].name} used to be.`, 'fizzle');
} else {
if (m.createdTurn === turn && phase === 'main') facts.newMonsterAttacked = m.name;
hurt(target, m.attack, `the ${m.name.toLowerCase()}`);
hurt(target, m.attack, `the ${m.name.toLowerCase()}`, m.owner);
}
}
@@ -859,7 +863,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
facts.shieldBlocked.add(target);
log(`${w.name}'s knife skids off ${guard(target)}.`, 'fizzle');
} else {
hurt(target, 1, `${w.name}'s knife`);
hurt(target, 1, `${w.name}'s knife`, id);
}
});
}
+15 -3
View File
@@ -81,6 +81,8 @@ export interface WizardState {
permanencyTurns: number;
banked: BankedSpell | null;
usedQuickLightning: boolean;
/** The wizard whose spell, creature or knife hurt this one most recently. */
lastHurtBy?: WizardId;
}
export interface Monster {
@@ -171,7 +173,9 @@ export interface TurnInput {
release?: { target?: TargetId; monsterTarget?: TargetId };
stabTarget?: TargetId;
monsterOrders?: Record<string, TargetId>;
/** When this wizard charmed the opponent last turn: the gesture the charmed hand makes. */
/** When this wizard charmed another last turn: the gesture the charmed hand makes, by charmed wizard. */
charmGestures?: Record<WizardId, Gesture>;
/** The same for one charmed wizard, when only one was charmed. */
charmGesture?: Gesture;
}
@@ -190,9 +194,17 @@ export function opponentsOf(state: GameState, id: WizardId): WizardId[] {
return state.seats.filter((s) => s !== id && inDuel(state.wizards[s]));
}
/** The wizard a spell goes at when no target is named: the first opponent still standing. */
/**
* The wizard a spell goes at when no target is named: whoever hurt this one
* most recently if they still stand, else the weakest opponent standing, else
* anyone else at the table.
*/
export function enemyOf(state: GameState, id: WizardId): WizardId {
return opponentsOf(state, id)[0] ?? state.seats.find((s) => s !== id) ?? id;
const standing = opponentsOf(state, id);
const grudge = state.wizards[id]?.lastHurtBy;
if (grudge && standing.includes(grudge)) return grudge;
if (standing.length) return [...standing].sort((a, b) => state.wizards[a].hp - state.wizards[b].hp)[0];
return state.seats.find((s) => s !== id) ?? id;
}
function makeWizard(id: WizardId, name: string): WizardState {
+3
View File
@@ -19,8 +19,11 @@ export function viewFor(state: GameState, viewer: WizardId): GameState {
export interface RoomView {
roomId: string;
seq: number;
/** The most seats the table will take. */
size: number;
me: WizardId;
/** The wizard who opened the table and may begin the duel. */
host: WizardId;
seats: { id: WizardId; name: string; bot: boolean }[];
/** Seats that still have to move before the turn resolves. */
waitingOn: WizardId[];
+59 -5
View File
@@ -113,7 +113,7 @@
<section class="lobby">
<p class="eyebrow">room {roomId}</p>
<h2>Take a seat</h2>
<p>Another wizard has opened this duel and is waiting for an opponent.</p>
<p>A wizard has opened this table and is gathering opponents.</p>
<form class="rename" onsubmit={takeSeat}>
<label class="field">
<span>Your name</span>
@@ -123,13 +123,34 @@
</form>
</section>
{:else if room && lobby}
{@const r = room.remote!}
{@const hostName = r.seats.find((s) => s.id === r.host)?.name ?? 'the host'}
<section class="lobby">
<p class="eyebrow">room {roomId}</p>
<h2>Waiting for {room.remote!.size - room.remote!.seats.length === 1 ? 'one more wizard' : `${room.remote!.size - room.remote!.seats.length} more wizards`}</h2>
<p>Send this link, or the code. Whoever opens it takes the empty seat, and the duel begins.</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.</p>
<p class="link-row"><code>{link}</code><button type="button" class="quiet" onclick={copyLink}>{copied ? 'Copied' : 'Copy link'}</button></p>
<p class="muted">so far: {room.remote!.seats.map((s) => s.name).join(', ')}</p>
<p><button type="button" class="quiet" onclick={() => room?.addBot()}>Seat the bot instead</button></p>
<ul class="roster">
{#each r.seats as s (s.id)}
<li>
<span class="seat-name">{s.name}</span>
<span class="muted">{s.id === r.host ? 'opened the table' : s.bot ? 'the bot' : 'seated'}{s.id === r.seat ? ', you' : ''}</span>
</li>
{/each}
<li class="empty muted">{r.size - r.seats.length} of {r.size} seats empty</li>
</ul>
<div class="ways">
{#if r.seats.length < r.size}
<button type="button" class="quiet" onclick={() => room?.addBot()}>Seat a bot</button>
{/if}
{#if room.isHost}
<button type="button" class="reveal small" disabled={r.seats.length < 2} onclick={() => room?.begin()}>Begin the duel</button>
<span class="muted">{r.seats.length < 2 ? 'once a second wizard is seated' : `with ${r.seats.length} wizards`}</span>
{:else}
<span class="muted">{hostName} will begin the duel when the table is ready.</span>
{/if}
</div>
{#if r.error}<p class="notice">{r.error}</p>{/if}
</section>
{:else if room}
<Board duel={room} />
@@ -245,6 +266,39 @@
font-size: 1rem;
}
.roster {
list-style: none;
margin: 0.9rem 0 0.6rem;
padding: 0;
max-width: 28em;
}
.roster li {
display: flex;
justify-content: space-between;
gap: 1rem;
padding: 0.3rem 0;
border-bottom: 1px solid var(--rule);
}
.roster li.empty {
border-bottom: 0;
justify-content: flex-start;
font-style: italic;
}
.seat-name {
font-weight: 500;
}
.ways {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 0.9rem;
margin-top: 0.4rem;
}
.link-row {
display: flex;
flex-wrap: wrap;