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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
This commit is contained in:
Eric Wagoner
2026-09-23 11:21:13 -04:00
co-authored by Claude Fable 5.1
parent c24fccaa35
commit 5c79a65319
19 changed files with 473 additions and 60 deletions
+1
View File
@@ -38,6 +38,7 @@ async function main(): Promise<void> {
try { try {
for (const line of lines) { for (const line of lines) {
if (line.t === 'seat') seats.push(line); 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 === 'start') state = createGame(Object.fromEntries(seats.map((s) => [s.id, s.name])), line.seed, line.rules ?? 1);
else if (line.t === 'turn' && state) { else if (line.t === 'turn' && state) {
state = resolveTurn(state, line.inputs as Record<WizardId, TurnInput>); state = resolveTurn(state, line.inputs as Record<WizardId, TurnInput>);
+2
View File
@@ -87,6 +87,8 @@ for f in glob.glob("/var/lib/waving-hands/rooms/*.jsonl"):
if x["t"] == "seat": if x["t"] == "seat":
(bots_seated if x["bot"] else humans).append(x["name"]) (bots_seated if x["bot"] else humans).append(x["name"])
names[x["id"]] = 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": elif x["t"] == "start":
started = True started = True
elif x["t"] == "turn": elif x["t"] == "turn":
+1
View File
@@ -79,6 +79,7 @@ for f in glob.glob("/var/lib/waving-hands/rooms/*.jsonl"):
if x["t"] == "seat": if x["t"] == "seat":
if x["bot"]: bots += 1 if x["bot"]: bots += 1
else: humans.add(x["name"]) 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] day_turns = [x for x in lines if x["t"] == "turn" and t0 <= x["at"] / 1000 < t1]
turns += len(day_turns) 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: # A duel finished today: its last turn is today's and the engine would say so; approximate by replay-free means:
+7
View File
@@ -5,6 +5,7 @@
// 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
// 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 // 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
// POST /api/rooms/:id/say {token, text} table talk, from a seat to the whole room // 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<void>
const seat = rooms.seatOf(room, body.token as string); const seat = rooms.seatOf(room, body.token as string);
return send(res, 200, rooms.view(room, seat.id)); 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') { if (action === 'begin') {
const token = typeof body.token === 'string' ? body.token : undefined; const token = typeof body.token === 'string' ? body.token : undefined;
rooms.begin(room, token); rooms.begin(room, token);
+16 -3
View File
@@ -136,7 +136,7 @@ export class Rooms {
return this.sit(room, name, false); 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 { begin(room: Room, token: string | undefined): void {
const seat = this.seatOf(room, token); 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 (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); if (room.seats.length >= room.size) throw new RoomError('Every seat at this duel is taken.', 409);
const name = cleanName(rawName); 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); 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 }); 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; 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. */ /** Record a seat's move; resolve the turn once nobody else is awaited. */
submit(room: Room, token: string | undefined, raw: unknown): void { submit(room: Room, token: string | undefined, raw: unknown): void {
const seat = this.seatOf(room, token); const seat = this.seatOf(room, token);
@@ -278,6 +288,9 @@ function apply(room: Room, line: LedgerLine): void {
case 'seat': case 'seat':
room.seats.push({ id: line.id, name: line.name, token: line.token, bot: line.bot }); room.seats.push({ id: line.id, name: line.name, token: line.token, bot: line.bot });
break; break;
case 'unseat':
room.seats = room.seats.filter((s) => s.id !== line.id);
break;
case 'start': case 'start':
room.state = createGame(Object.fromEntries(room.seats.map((s) => [s.id, s.name])), line.seed, line.rules ?? 1); room.state = createGame(Object.fromEntries(room.seats.map((s) => [s.id, s.name])), line.seed, line.rules ?? 1);
break; break;
+2
View File
@@ -9,6 +9,8 @@ import type { TurnInput, WizardId } from '../../src/lib/game/state';
export type LedgerLine = export type LedgerLine =
| { t: 'room'; id: string; seats: number; createdAt: number } | { t: 'room'; id: string; seats: number; createdAt: number }
| { t: 'seat'; id: WizardId; name: string; token: string; bot: boolean } | { 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. */ /** Ledgers from before revisions were recorded resolve under rules 1. */
| { t: 'start'; seed: number; rules?: number } | { t: 'start'; seed: number; rules?: number }
| { t: 'turn'; at: number; inputs: Record<WizardId, TurnInput> } | { t: 'turn'; at: number; inputs: Record<WizardId, TurnInput> }
+37 -11
View File
@@ -3,11 +3,13 @@
import HandPicker from './HandPicker.svelte'; import HandPicker from './HandPicker.svelte';
import Ledger from './Ledger.svelte'; import Ledger from './Ledger.svelte';
import MobileBar from './MobileBar.svelte'; import MobileBar from './MobileBar.svelte';
import Roster from './Roster.svelte';
import SpellSheet from './SpellSheet.svelte'; import SpellSheet from './SpellSheet.svelte';
import TableTalk from './TableTalk.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';
import { seatColor } from '$lib/game/colors';
import { GESTURE_SHORT, glyph } from '$lib/game/glyphs'; import { GESTURE_SHORT, glyph } from '$lib/game/glyphs';
import { MAGIC_GESTURES } from '$lib/game/spells'; import { MAGIC_GESTURES } from '$lib/game/spells';
@@ -16,6 +18,8 @@
const foeMonsters = $derived(duel.state.monsters.filter((m) => m.owner !== duel.me)); const foeMonsters = $derived(duel.state.monsters.filter((m) => m.owner !== duel.me));
const outcome = $derived(duel.state.over); const outcome = $derived(duel.state.over);
const won = $derived(outcome?.winner === duel.me); 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);
</script> </script>
<main class="board"> <main class="board">
@@ -58,11 +62,11 @@
{#if duel.threatsNow.length} {#if duel.threatsNow.length}
<p>Their hands could finish this turn:</p> <p>Their hands could finish this turn:</p>
<ul> <ul>
{#each duel.threatsNow as t (t.hand + t.spell.id)} {#each duel.threatsNow as t (duel.threatKey(t))}
{@const active = duel.isHighlighted(t)} {@const active = duel.isHighlighted(t)}
<li> <li>
<button type="button" class="threat soon" class:active aria-pressed={active} onclick={() => duel.toggleHighlight(t)}> <button type="button" class="threat soon" class:active aria-pressed={active} onclick={() => duel.toggleHighlight(t)}>
<span class="thand">{duel.others.length > 1 ? `${duel.state.wizards[t.who].name}, ` : ''}{t.hand}</span> <span class="thand">{#if duel.others.length > 1}<span class="dot" style="background: {seatColor(duel.state, t.who)}"></span>{`${duel.state.wizards[t.who].name}, `}{/if}{t.hand}</span>
<span class="tseq">{tokensText(t.done)} → <strong>{tokensText(t.remaining)}</strong></span> <span class="tseq">{tokensText(t.done)} → <strong>{tokensText(t.remaining)}</strong></span>
<span class="tname">{t.spell.name}</span> <span class="tname">{t.spell.name}</span>
</button> </button>
@@ -74,11 +78,11 @@
<details class="next" open={!duel.compact}> <details class="next" open={!duel.compact}>
<summary class="muted">Two gestures away ({duel.threatsNext.length})</summary> <summary class="muted">Two gestures away ({duel.threatsNext.length})</summary>
<ul> <ul>
{#each duel.threatsNext as t (t.hand + t.spell.id)} {#each duel.threatsNext as t (duel.threatKey(t))}
{@const active = duel.isHighlighted(t)} {@const active = duel.isHighlighted(t)}
<li> <li>
<button type="button" class="threat" class:active aria-pressed={active} onclick={() => duel.toggleHighlight(t)}> <button type="button" class="threat" class:active aria-pressed={active} onclick={() => duel.toggleHighlight(t)}>
<span class="thand">{duel.others.length > 1 ? `${duel.state.wizards[t.who].name}, ` : ''}{t.hand}</span> <span class="thand">{#if duel.others.length > 1}<span class="dot" style="background: {seatColor(duel.state, t.who)}"></span>{`${duel.state.wizards[t.who].name}, `}{/if}{t.hand}</span>
<span class="tseq">{tokensText(t.done)} → {tokensText(t.remaining)}</span> <span class="tseq">{tokensText(t.done)} → {tokensText(t.remaining)}</span>
<span class="tname">{t.spell.name}</span> <span class="tname">{t.spell.name}</span>
</button> </button>
@@ -118,9 +122,9 @@
{#if duel.stabbing} {#if duel.stabbing}
<label class="field"> <label class="field">
<span>Stab</span> <span>Stab</span>
<select bind:value={duel.stabTarget}> <select bind:value={duel.stabTarget} onchange={() => duel.focusOn(duel.stabTarget)}>
{#each duel.others as id (id)} {#each duel.others as id (id)}
<option value={id}>{duel.state.wizards[id].name}</option> <option value={id}>{crowded ? duel.targetLabel(id) : duel.state.wizards[id].name}</option>
{/each} {/each}
{#each foeMonsters as m (m.id)} {#each foeMonsters as m (m.id)}
<option value={m.id}>{m.name}</option> <option value={m.id}>{m.name}</option>
@@ -131,9 +135,9 @@
{#each duel.yourMonsters as m (m.id)} {#each duel.yourMonsters as m (m.id)}
<label class="field"> <label class="field">
<span>{m.name} attacks</span> <span>{m.name} attacks</span>
<select bind:value={duel.monsterOrders[m.id]}> <select bind:value={duel.monsterOrders[m.id]} onchange={() => duel.focusOn(duel.monsterOrders[m.id])}>
{#each duel.others as id (id)} {#each duel.others as id (id)}
<option value={id}>{duel.state.wizards[id].name}</option> <option value={id}>{crowded ? duel.targetLabel(id) : duel.state.wizards[id].name}</option>
{/each} {/each}
{#each foeMonsters as fm (fm.id)} {#each foeMonsters as fm (fm.id)}
<option value={fm.id}>{fm.name}</option> <option value={fm.id}>{fm.name}</option>
@@ -187,6 +191,9 @@
{#if duel.remote?.error} {#if duel.remote?.error}
<p class="warning">{duel.remote.error}</p> <p class="warning">{duel.remote.error}</p>
{/if} {/if}
{#if duel.intentText && !duel.moved}
<p class="intent">{duel.intentText}</p>
{/if}
{#if duel.moved} {#if duel.moved}
<p class="waiting">Your move is in. Waiting for {duel.awaitingText}.</p> <p class="waiting">Your move is in. Waiting for {duel.awaitingText}.</p>
{/if} {/if}
@@ -209,9 +216,13 @@
{#if !duel.spectating} {#if !duel.spectating}
<WizardStatus wizard={duel.you} monsters={duel.yourMonsters} you /> <WizardStatus wizard={duel.you} monsters={duel.yourMonsters} you />
{/if} {/if}
{#each duel.others as id (id)} {#if crowded}
<WizardStatus wizard={duel.state.wizards[id]} monsters={duel.state.monsters.filter((m) => m.owner === id)} /> <Roster {duel} />
{/each} {:else}
{#each duel.others as id (id)}
<WizardStatus wizard={duel.state.wizards[id]} monsters={duel.state.monsters.filter((m) => m.owner === id)} />
{/each}
{/if}
<TableTalk {duel} /> <TableTalk {duel} />
<SpellSheet {duel} /> <SpellSheet {duel} />
<Chronicle log={duel.state.log} /> <Chronicle log={duel.state.log} />
@@ -252,6 +263,21 @@
min-width: 0; min-width: 0;
} }
.dot {
display: inline-block;
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
margin-right: 0.3rem;
vertical-align: baseline;
}
.intent {
margin: 0.6rem 0 0.2rem;
font-style: italic;
color: var(--bone);
}
.move { .move {
padding: 0.6rem 0 1.5rem; padding: 0.6rem 0 1.5rem;
} }
+1 -1
View File
@@ -93,7 +93,7 @@
{#if spell} {#if spell}
<label class="field"> <label class="field">
<span>at</span> <span>at</span>
<select bind:value={draft.target}> <select bind:value={draft.target} onchange={() => duel.focusOn(draft.target)}>
{#each duel.targetsFor(spell) as t (t.id)} {#each duel.targetsFor(spell) as t (t.id)}
<option value={t.id}>{t.label}</option> <option value={t.id}>{t.label}</option>
{/each} {/each}
+72 -1
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { Duel } from '$lib/game/duel.svelte'; import type { Duel } from '$lib/game/duel.svelte';
import { seatColor } from '$lib/game/colors';
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 { hiddenFromViewer, type HistoryEntry, type WizardId } from '$lib/game/state'; import { hiddenFromViewer, type HistoryEntry, type WizardId } from '$lib/game/state';
@@ -71,15 +72,41 @@
void turns; void turns;
if (scroller) scroller.scrollTop = scroller.scrollHeight; if (scroller) scroller.scrollTop = scroller.scrollHeight;
}); });
const crowded = $derived(duel.others.length >= 3);
// The focused wizard's columns slide in just past the pinned ones.
$effect(() => {
const who = duel.focus;
void duel.focusTick;
if (!who || !scroller) return;
const th = scroller.querySelector<HTMLElement>(`th[data-who="${who}"]`);
const pinned = scroller.querySelector<HTMLElement>('thead th.pin-right');
if (!th) return;
const left = pinned ? pinned.offsetLeft + pinned.offsetWidth : 0;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
scroller.scrollTo({ left: th.offsetLeft - left, behavior: reduce ? 'auto' : 'smooth' });
});
</script> </script>
{#if crowded}
<nav class="seats" aria-label="Wizards in the ledger">
<span class="muted">Turn the ledger toward</span>
{#each duel.others as who (who)}
{@const w = duel.state.wizards[who]}
<button type="button" class="seat" class:focused={duel.focus === who} class:gone={!w.alive || w.surrendered} onclick={() => duel.focusOn(who)}>
<span class="dot" style="background: {seatColor(duel.state, who)}"></span>{w.name}
</button>
{/each}
</nav>
{/if}
<div class="scroller" bind:this={scroller}> <div class="scroller" bind:this={scroller}>
<table class="ledger"> <table class="ledger">
<thead> <thead>
<tr> <tr>
<th class="turn" scope="col"><span class="visually-hidden">Turn</span></th> <th class="turn" scope="col"><span class="visually-hidden">Turn</span></th>
{#each columns as who (who)} {#each columns as who (who)}
<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> <th data-who={who} class:you={who === duel.me} class:pin-left={who === duel.me} class:foe={who !== duel.me} class:focused={duel.focus === who} class:gone={!duel.state.wizards[who].alive || duel.state.wizards[who].surrendered} colspan="2" scope="colgroup">{#if crowded && who !== duel.me}<span class="dot" style="background: {seatColor(duel.state, who)}"></span>{/if}{duel.state.wizards[who].name}{#if who === duel.me}&nbsp;(you){/if}</th>
{/each} {/each}
</tr> </tr>
<tr class="hands"> <tr class="hands">
@@ -144,6 +171,50 @@
</div> </div>
<style> <style>
.seats {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.3rem 0.4rem;
padding: 0.2rem 0 0.5rem;
font-size: 0.85rem;
}
.seat {
background: none;
border: 1px solid var(--rule);
border-radius: 999px;
padding: 0.1rem 0.6rem 0.1rem 0.45rem;
color: var(--bone-dim);
font-style: italic;
}
.seat:hover {
border-color: var(--bone-dim);
color: var(--bone);
}
.seat.focused {
border-color: var(--frost);
color: var(--bone);
}
.seat.gone {
text-decoration: line-through;
}
.dot {
display: inline-block;
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
margin-right: 0.3rem;
}
thead th.focused {
color: var(--frost);
}
.scroller { .scroller {
max-height: clamp(170px, 30vh, 400px); max-height: clamp(170px, 30vh, 400px);
overflow: auto; overflow: auto;
+145
View File
@@ -0,0 +1,145 @@
<script lang="ts">
// A crowded table's rail: every other wizard on one line, with the one
// you are looking at opened out. Your own panel stays in full above.
import WizardStatus from './WizardStatus.svelte';
import type { Duel } from '$lib/game/duel.svelte';
import { seatColor } from '$lib/game/colors';
import { MAX_HP, type WizardId } from '$lib/game/state';
import { statusWords } from '$lib/game/status';
let { duel }: { duel: Duel } = $props();
const waitingOn = $derived(duel.remote?.waitingOn ?? []);
function toggle(id: WizardId) {
if (duel.focus === id) duel.focus = null;
else duel.focusOn(id);
}
function stateOf(id: WizardId): string {
if (!duel.remote?.started || duel.state.over) return '';
const w = duel.state.wizards[id];
if (!w.alive || w.surrendered) return 'out';
const bot = duel.remote.seats.find((s) => s.id === id)?.bot;
if (bot) return '';
return waitingOn.includes(id) ? 'writing' : 'moved';
}
</script>
<section class="roster" aria-label="The other wizards">
<h2>At the table</h2>
<ul>
{#each duel.others as id (id)}
{@const w = duel.state.wizards[id]}
{@const open = duel.focus === id}
{@const words = statusWords(w)}
<li class:open class:gone={!w.alive || w.surrendered}>
<button type="button" class="row" aria-expanded={open} onclick={() => toggle(id)}>
<span class="dot" style="background: {seatColor(duel.state, id)}"></span>
<span class="name">{w.name}</span>
<span class="hp">{w.hp}/{MAX_HP}</span>
<span class="words">{words.join(', ')}</span>
<span class="turn muted">{stateOf(id)}</span>
</button>
{#if open}
<div class="detail">
<WizardStatus wizard={w} monsters={duel.state.monsters.filter((m) => m.owner === id)} />
</div>
{/if}
</li>
{/each}
</ul>
</section>
<style>
.roster {
padding: 0.75rem 0 0.4rem;
border-bottom: 1px solid var(--rule);
}
h2 {
font-size: 1.1rem;
font-style: italic;
margin-bottom: 0.3rem;
}
ul {
list-style: none;
margin: 0;
padding: 0;
}
li {
border-top: 1px solid var(--rule);
}
li.gone .name {
text-decoration: line-through;
color: var(--bone-dim);
}
.row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto minmax(0, 1.4fr) auto;
align-items: baseline;
gap: 0.5rem;
width: 100%;
padding: 0.35rem 0.2rem;
background: none;
border: 0;
color: var(--bone);
text-align: left;
font-size: 0.92rem;
}
.row:hover,
li.open .row {
background: var(--slate);
}
.dot {
width: 0.55rem;
height: 0.55rem;
border-radius: 50%;
align-self: center;
}
.name {
font-style: italic;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hp {
font-variant-numeric: tabular-nums;
color: var(--bone-dim);
}
.words {
color: var(--frost);
font-size: 0.82rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.turn {
font-size: 0.78rem;
font-style: italic;
white-space: nowrap;
}
.detail {
padding: 0 0.2rem 0.3rem;
}
.detail :global(.wizard) {
border-bottom: 0;
padding-top: 0.2rem;
}
.detail :global(h2) {
display: none;
}
</style>
+15 -10
View File
@@ -1,9 +1,12 @@
<script lang="ts"> <script lang="ts">
import type { Duel } from '$lib/game/duel.svelte'; import { listText, type Duel } from '$lib/game/duel.svelte';
let { duel }: { duel: Duel } = $props(); let { duel }: { duel: Duel } = $props();
const summary = $derived(duel.summary); const summary = $derived(duel.summary);
const touched = $derived(summary ? duel.state.seats.filter((who) => summary.wizards[who].effects.length > 0) : []);
const untouched = $derived(summary ? duel.state.seats.filter((who) => summary.wizards[who].effects.length === 0) : []);
const nameOf = (who: string) => (who === duel.me ? 'You' : duel.state.wizards[who].name);
function signed(n: number): string { function signed(n: number): string {
return n > 0 ? `+${n}` : String(n); return n > 0 ? `+${n}` : String(n);
@@ -14,22 +17,24 @@
<section class="result" id="turn-result" aria-label="What the last reveal did"> <section class="result" id="turn-result" aria-label="What the last reveal did">
<h2>{summary.phase === 'timestop' ? 'In the stopped moment' : `Turn ${summary.turn + 1}`}</h2> <h2>{summary.phase === 'timestop' ? 'In the stopped moment' : `Turn ${summary.turn + 1}`}</h2>
<dl> <dl>
{#each duel.state.seats as who (who)} {#each touched as who (who)}
{@const w = summary.wizards[who]} {@const w = summary.wizards[who]}
<div> <div>
<dt>{who === duel.me ? 'You' : duel.state.wizards[who].name}</dt> <dt>{nameOf(who)}</dt>
<dd> <dd>
{#if w.effects.length === 0} {#each w.effects as e, i (i)}
<span class="muted">untouched</span> <span class="effect" class:hurt={e.delta < 0} class:healed={e.delta > 0}>{e.label} {signed(e.delta)}</span>
{:else} {/each}
{#each w.effects as e, i (i)}
<span class="effect" class:hurt={e.delta < 0} class:healed={e.delta > 0}>{e.label} {signed(e.delta)}</span>
{/each}
{/if}
<span class="hp" class:changed={w.before !== w.after}>health {w.before}{#if w.before !== w.after} → {w.after}{/if}</span> <span class="hp" class:changed={w.before !== w.after}>health {w.before}{#if w.before !== w.after} → {w.after}{/if}</span>
</dd> </dd>
</div> </div>
{/each} {/each}
{#if untouched.length}
<div>
<dt class="muted">{touched.length === 0 ? 'Nobody' : untouched.length > 2 ? `${untouched.length} others` : listText(untouched.map(nameOf))}</dt>
<dd><span class="muted">{touched.length === 0 ? 'No health changes.' : 'untouched'}</span></dd>
</div>
{/if}
</dl> </dl>
{#if summary.events.length} {#if summary.events.length}
<ul class="events"> <ul class="events">
+2 -25
View File
@@ -1,33 +1,10 @@
<script lang="ts"> <script lang="ts">
import { SPELL_BY_ID } from '$lib/game/spells'; import { effectsOf } from '$lib/game/status';
import { MAX_HP, type Monster, type WizardState } from '$lib/game/state'; import { MAX_HP, type Monster, type WizardState } from '$lib/game/state';
let { wizard, monsters, you = false }: { wizard: WizardState; monsters: Monster[]; you?: boolean } = $props(); let { wizard, monsters, you = false }: { wizard: WizardState; monsters: Monster[]; you?: boolean } = $props();
const more = (n: number) => (n === Infinity ? 'for good' : `${n} more`); const effects = $derived(effectsOf(wizard));
const effects = $derived.by(() => {
const out: string[] = [];
if (wizard.resistHeat) out.push('resists heat');
if (wizard.resistCold) out.push('resists cold');
if (wizard.protectionTurns > 0) out.push(`protected from evil, ${more(wizard.protectionTurns)}`);
if (wizard.blindTurns > 0) out.push(`blind, ${more(wizard.blindTurns)}`);
if (wizard.invisibleTurns > 0) out.push(`invisible, ${more(wizard.invisibleTurns)}`);
if (wizard.hasteTurns > 0) out.push(`hastened, ${more(wizard.hasteTurns)}`);
if (wizard.banked) out.push(`holding a banked ${SPELL_BY_ID[wizard.banked.spellId].name.toLowerCase()}`);
else if (wizard.delayedTurns > 0) out.push(`next spell will be banked, ${more(wizard.delayedTurns)}`);
if (wizard.permanencyTurns > 0) out.push(`next enchantment will be permanent, ${more(wizard.permanencyTurns)}`);
if (wizard.diseaseTurns !== null) out.push(`diseased, dies in ${wizard.diseaseTurns}`);
if (wizard.poisonTurns !== null) out.push(`poisoned, dies in ${wizard.poisonTurns}`);
const c = wizard.constraints;
if (c.amnesia) out.push(wizard.permanent.amnesia ? 'amnesia for good' : 'amnesia this turn');
if (c.confusion) out.push(wizard.permanent.confusion ? 'confused for good' : 'confused this turn');
if (c.fear) out.push(wizard.permanent.fear ? 'afraid for good' : 'afraid this turn');
if (c.paralysedHand) out.push(`${c.paralysedHand} hand paralysed${wizard.permanent.paralysedHand ? ' for good' : ''}`);
if (c.charmed) out.push(`${c.charmed.hand} hand charmed${wizard.permanent.charmed ? ' for good' : ''}`);
if (wizard.usedQuickLightning) out.push('quick lightning spent');
return out;
});
</script> </script>
<section class="wizard" class:you class:dead={!wizard.alive}> <section class="wizard" class:you class:dead={!wizard.alive}>
+1
View File
@@ -87,6 +87,7 @@ export const api = {
join: (roomId: string, name: string) => call<Seated>('POST', `/api/rooms/${roomId}/join`, { name }), 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 }), 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 }),
unseat: (roomId: string, token: string, seat: WizardId) => call<RoomView>('POST', `/api/rooms/${roomId}/unseat`, { token, seat }),
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)}`),
/** The gallery's view: no token, no seat, nothing any wizard could not see. */ /** The gallery's view: no token, no seat, nothing any wizard could not see. */
watch: (roomId: string) => call<RoomView>('GET', `/api/rooms/${roomId}`), watch: (roomId: string) => call<RoomView>('GET', `/api/rooms/${roomId}`),
+12
View File
@@ -0,0 +1,12 @@
// One colour per seat, the same in the roster, the ledger and the threats,
// so a name met in one place is known in the next. Twelve hues spaced round
// the wheel at the palette's own saturation, so none shouts.
import type { GameState, WizardId } from './state';
const HUES = [28, 200, 96, 330, 52, 260, 160, 8, 220, 120, 300, 70];
export function seatColor(state: GameState, id: WizardId): string {
const i = Math.max(0, state.seats.indexOf(id));
return `hsl(${HUES[i % HUES.length]} 55% 62%)`;
}
+62 -3
View File
@@ -18,6 +18,7 @@ import {
} from './gestures'; } from './gestures';
import { glyph } from './glyphs'; import { glyph } from './glyphs';
import { allowedGestures, forcedGesture, resolveTurn } from './resolve'; import { allowedGestures, forcedGesture, resolveTurn } from './resolve';
import { statusWords } from './status';
import { likelyTarget } from './targets'; import { likelyTarget } from './targets';
import { import {
GESTURES, GESTURES,
@@ -38,6 +39,7 @@ import {
createGame, createGame,
enemyOf, enemyOf,
inDuel, inDuel,
isWizardId,
visibleHistory, visibleHistory,
type CastChoice, type CastChoice,
type GameState, type GameState,
@@ -249,6 +251,10 @@ export class Duel {
sheetOpen = $state(false); sheetOpen = $state(false);
/** Opponent gestures to light up in the ledger. */ /** Opponent gestures to light up in the ledger. */
highlight = $state<Highlight | null>(null); highlight = $state<Highlight | null>(null);
/** The wizard the ledger and the roster are turned toward, at a crowded table. */
focus = $state<WizardId | null>(null);
/** Bumped each time focus is asked for, so asking for the same wizard twice scrolls twice. */
focusTick = $state(0);
/** A turn is on its way to the server. */ /** A turn is on its way to the server. */
sending = $state(false); sending = $state(false);
@@ -493,6 +499,21 @@ export class Duel {
const h = this.highlight; const h = this.highlight;
const same = h && h.who === t.who && h.hand === t.hand && h.from === t.from && h.to === t.to; const same = h && h.who === t.who && h.hand === t.hand && h.from === t.from && h.to === t.to;
this.highlight = same ? null : { who: t.who, hand: t.hand, from: t.from, to: t.to }; this.highlight = same ? null : { who: t.who, hand: t.hand, from: t.from, to: t.to };
if (!same) this.focusOn(t.who);
}
/** One key per threat: the same spell on the same hand can be under way at several seats. */
threatKey(t: Threat): string {
return `${t.who}:${t.hand}:${t.spell.id}`;
}
/** Turn the ledger and the roster toward a wizard; a creature's owner stands in for the creature. */
focusOn(target: TargetId | ''): void {
if (!target) return;
const who = isWizardId(this.state, target) ? target : this.state.monsters.find((m) => m.id === target)?.owner;
if (!who || who === this.me) return;
this.focus = who;
this.focusTick += 1;
} }
/** Whether a threat is the one lit up in the ledger. */ /** Whether a threat is the one lit up in the ledger. */
@@ -587,6 +608,24 @@ export class Duel {
} }
surrendering = $derived(this.activeSets.some((set) => this.gestureFor(set, 'left') === 'P' && this.gestureFor(set, 'right') === 'P')); surrendering = $derived(this.activeSets.some((set) => this.gestureFor(set, 'left') === 'P' && this.gestureFor(set, 'right') === 'P'));
/** What the reveal will do, in a sentence: "Shield yourself; Missile at Lucan; stab Wynne." */
intentText = $derived.by(() => {
const name = (id: TargetId | '') => {
if (!id || id === this.me) return 'yourself';
if (id === NOWHERE) return 'nowhere';
if (isWizardId(this.state, id)) return this.state.wizards[id].name;
return this.state.monsters.find((m) => m.id === id)?.name ?? id;
};
const parts: string[] = [];
for (const p of this.planned) {
const at = p.draft.target || this.defaultTarget(p.completion.spell);
parts.push(at === this.me || !at ? `${p.completion.spell.name} yourself` : `${p.completion.spell.name} at ${name(at)}`);
}
if (this.stabbing) parts.push(`stab ${name(this.stabTarget)}`);
if (!parts.length) return '';
const text = parts.join('; ');
return text.charAt(0).toUpperCase() + text.slice(1) + '.';
});
stabbing = $derived(this.activeSets.some((set) => HANDS.some((h) => this.gestureFor(set, h) === '>'))); stabbing = $derived(this.activeSets.some((set) => HANDS.some((h) => this.gestureFor(set, h) === '>')));
yourMonsters = $derived(this.state.monsters.filter((m) => m.owner === this.me)); yourMonsters = $derived(this.state.monsters.filter((m) => m.owner === this.me));
ready = $derived( ready = $derived(
@@ -627,10 +666,18 @@ export class Duel {
return spell ? likelyTarget(this.state, this.me, spell) : ''; return spell ? likelyTarget(this.state, this.me, spell) : '';
} }
/** A wizard's name with what a caster needs to know: health and the conditions that matter. */
targetLabel(id: WizardId): string {
const w = this.state.wizards[id];
const words = statusWords(w);
return [w.name, `${w.hp} HP`, ...words].join(' · ');
}
/** Every possible subject, the likeliest first, creatures marked by whose they are. */ /** Every possible subject, the likeliest first, creatures marked by whose they are. */
targetsFor(spell: Spell | undefined): { id: TargetId; label: string }[] { targetsFor(spell: Spell | undefined): { id: TargetId; label: string }[] {
const list: { id: TargetId; label: string }[] = [{ id: this.me, label: 'yourself' }]; const list: { id: TargetId; label: string }[] = [{ id: this.me, label: 'yourself' }];
for (const id of this.others) list.push({ id, label: this.state.wizards[id].name }); const crowded = this.others.length > 1;
for (const id of this.others) list.push({ id, label: crowded ? this.targetLabel(id) : this.state.wizards[id].name });
for (const m of this.state.monsters) { for (const m of this.state.monsters) {
list.push({ id: m.id, label: `${m.name} (${m.owner === this.me ? 'yours' : `${this.state.wizards[m.owner].name}'s`})` }); list.push({ id: m.id, label: `${m.name} (${m.owner === this.me ? 'yours' : `${this.state.wizards[m.owner].name}'s`})` });
} }
@@ -798,6 +845,17 @@ export class Duel {
} }
} }
/** Send a bot away before the duel begins; only the host may. */
async unseat(id: WizardId): Promise<void> {
const r = this.remote;
if (!r) return;
try {
this.apply(await api.unseat(r.roomId, r.token, id));
} catch (e) {
r.error = e instanceof Error ? e.message : 'The bot could not be sent away.';
}
}
/** Seat the bot in an empty chair. */ /** Seat the bot in an empty chair. */
async addBot(): Promise<void> { async addBot(): Promise<void> {
const r = this.remote; const r = this.remote;
@@ -863,11 +921,12 @@ export class Duel {
return remoteFrom(seated.view, seated.token); return remoteFrom(seated.view, seated.token);
} }
/** A room with the bot already seated: a duel the server keeps, so it can be replayed and seen. */ /** A room with the bot seated and the duel begun: a duel the server keeps, so it can be replayed and seen. */
static async createBotRoom(name: string): Promise<Remote> { static async createBotRoom(name: string): Promise<Remote> {
const seated = await api.create(name, 2); const seated = await api.create(name, 2);
rememberSeat(seated.view.roomId, { seat: seated.seat, token: seated.token }); rememberSeat(seated.view.roomId, { seat: seated.seat, token: seated.token });
return remoteFrom(await api.addBot(seated.view.roomId, seated.token), seated.token); await api.addBot(seated.view.roomId, seated.token);
return remoteFrom(await api.begin(seated.view.roomId, seated.token), seated.token);
} }
static async joinRoom(roomId: string, name: string): Promise<Remote> { static async joinRoom(roomId: string, name: string): Promise<Remote> {
+23 -1
View File
@@ -1,8 +1,9 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { Duel } from './duel.svelte'; import { Duel } from './duel.svelte';
import { resolveTurn } from './resolve';
import { createGame, type GameState } from './state'; import { createGame, type GameState } from './state';
import type { Gesture } from './spells'; import type { Gesture } from './spells';
import { play } from './test-helpers'; import { noop, play } from './test-helpers';
/** A is one gesture from a lightning bolt and has just been struck by amnesia. */ /** A is one gesture from a lightning bolt and has just been struck by amnesia. */
function amnesiaBeforeLightning(): GameState { function amnesiaBeforeLightning(): GameState {
@@ -27,3 +28,24 @@ describe('the duel store', () => {
expect(duel.foe.hp).toBe(10); expect(duel.foe.hp).toBe(10);
}); });
}); });
describe('threats at a crowded table', () => {
it('keys each opponent apart when two build the same spell with the same hand', () => {
let s = createGame({ A: 'You', B: 'Bryn', C: 'Cass' }, 1);
s = resolveTurn(s, { A: noop(), B: { ...noop(), left: 'S' }, C: { ...noop(), left: 'S' } });
const duel = new Duel(s);
const missiles = duel.threatsNow.filter((t) => t.spell.id === 'missile');
expect(missiles.map((t) => t.who).sort()).toEqual(['B', 'C']);
const keys = duel.threats.map((t) => duel.threatKey(t));
expect(new Set(keys).size).toBe(keys.length);
});
it('labels targets with health and condition', () => {
let s = createGame({ A: 'You', B: 'Bryn', C: 'Cass' }, 1);
s = resolveTurn(s, { A: noop(), B: noop(), C: noop() });
s.wizards.C.hp = 8;
s.wizards.C.invisibleTurns = 2;
const duel = new Duel(s);
expect(duel.targetLabel('C')).toBe('Cass · 8 HP · invisible');
});
});
+54
View File
@@ -0,0 +1,54 @@
// A wizard's condition in words: the long form for their panel, the short
// form for a crowded roster and a target list.
import { SPELL_BY_ID } from './spells';
import type { WizardState } from './state';
const more = (n: number) => (n === Infinity ? 'for good' : `${n} more`);
/** Everything on a wizard, each with how long it lasts. */
export function effectsOf(w: WizardState): string[] {
const out: string[] = [];
if (w.resistHeat) out.push('resists heat');
if (w.resistCold) out.push('resists cold');
if (w.protectionTurns > 0) out.push(`protected from evil, ${more(w.protectionTurns)}`);
if (w.blindTurns > 0) out.push(`blind, ${more(w.blindTurns)}`);
if (w.invisibleTurns > 0) out.push(`invisible, ${more(w.invisibleTurns)}`);
if (w.hasteTurns > 0) out.push(`hastened, ${more(w.hasteTurns)}`);
if (w.banked) out.push(`holding a banked ${SPELL_BY_ID[w.banked.spellId].name.toLowerCase()}`);
else if (w.delayedTurns > 0) out.push(`next spell will be banked, ${more(w.delayedTurns)}`);
if (w.permanencyTurns > 0) out.push(`next enchantment will be permanent, ${more(w.permanencyTurns)}`);
if (w.diseaseTurns !== null) out.push(`diseased, dies in ${w.diseaseTurns}`);
if (w.poisonTurns !== null) out.push(`poisoned, dies in ${w.poisonTurns}`);
const c = w.constraints;
if (c.amnesia) out.push(w.permanent.amnesia ? 'amnesia for good' : 'amnesia this turn');
if (c.confusion) out.push(w.permanent.confusion ? 'confused for good' : 'confused this turn');
if (c.fear) out.push(w.permanent.fear ? 'afraid for good' : 'afraid this turn');
if (c.paralysedHand) out.push(`${c.paralysedHand} hand paralysed${w.permanent.paralysedHand ? ' for good' : ''}`);
if (c.charmed) out.push(`${c.charmed.hand} hand charmed${w.permanent.charmed ? ' for good' : ''}`);
if (w.usedQuickLightning) out.push('quick lightning spent');
return out;
}
/** The few words that matter when choosing a target or scanning a roster. */
export function statusWords(w: WizardState): string[] {
if (!w.alive) return ['dead'];
if (w.surrendered) return ['yielded'];
const out: string[] = [];
if (w.invisibleTurns > 0) out.push('invisible');
if (w.blindTurns > 0) out.push('blind');
if (w.protectionTurns > 0) out.push('protected');
if (w.resistHeat) out.push('resists heat');
if (w.resistCold) out.push('resists cold');
if (w.hasteTurns > 0) out.push('hastened');
if (w.diseaseTurns !== null) out.push(`dies in ${w.diseaseTurns}`);
if (w.poisonTurns !== null) out.push(`poisoned`);
const c = w.constraints;
if (c.paralysedHand) out.push(`${c.paralysedHand} hand paralysed`);
if (c.charmed) out.push('charmed');
if (c.amnesia) out.push('amnesia');
if (c.confusion) out.push('confused');
if (c.fear) out.push('afraid');
if (w.banked) out.push('holding a spell');
return out;
}
+3 -3
View File
@@ -35,7 +35,7 @@
<figure> <figure>
<img src="/guide/table.png" alt="A table being laid: the invite link, a roster of seated wizards, an empty-seat count, and the host's button to begin." /> <img src="/guide/table.png" alt="A table being laid: the invite link, a roster of seated wizards, an empty-seat count, and the host's button to begin." />
</figure> </figure>
<p>A table fills as wizards open the link. Whoever laid it is the host: they may seat a bot in any empty chair and they begin the duel when the company suits them, from two wizards to twelve. Once begun, the seats are closed and the ledger opens.</p> <p>A table fills as wizards open the link. Whoever laid it is the host: they may seat a bot in any empty chair or send one away, and they begin the duel when the company suits them, from two wizards to twelve; a full table waits for them too. Once begun, the seats are closed and the ledger opens.</p>
<p>A duel between people is played by turns, whenever each of you has a moment. The page keeps watch and fills itself in when the others have moved; the masthead's <em>room</em> button copies the invite for latecomers who wish to watch.</p> <p>A duel between people is played by turns, whenever each of you has a moment. The page keeps watch and fills itself in when the others have moved; the masthead's <em>room</em> button copies the invite for latecomers who wish to watch.</p>
</section> </section>
@@ -45,7 +45,7 @@
<img src="/guide/ledger.png" alt="The ledger: a table with a turn number down the left, two letter columns for each wizard, and spells named in ember beneath the gesture that finished them." /> <img src="/guide/ledger.png" alt="The ledger: a table with a turn number down the left, two letter columns for each wizard, and spells named in ember beneath the gesture that finished them." />
</figure> </figure>
<p>This is the game, as Bartle drew it: one row a turn, two columns a wizard, one letter a hand. <strong>F</strong> wriggled fingers, <strong>P</strong> proffered palm, <strong>S</strong> snap, <strong>W</strong> wave, <strong>D</strong> pointed digit, <strong>C</strong> clap. A dagger is a stab; a dash, a hand that rested. A gesture written in ember finished a spell, and the spell is named beneath it.</p> <p>This is the game, as Bartle drew it: one row a turn, two columns a wizard, one letter a hand. <strong>F</strong> wriggled fingers, <strong>P</strong> proffered palm, <strong>S</strong> snap, <strong>W</strong> wave, <strong>D</strong> pointed digit, <strong>C</strong> clap. A dagger is a stab; a dash, a hand that rested. A gesture written in ember finished a spell, and the spell is named beneath it.</p>
<p>The bottom row is the turn being written. Your letters appear as you choose them; your rivals' show as <strong>?</strong> until the reveal. A question mark in an older row means a gesture you were not allowed to see: you were blind, or they were invisible. With many wizards the ledger runs sideways; the turn and your own two columns stay pinned while you slide across to the rest.</p> <p>The bottom row is the turn being written. Your letters appear as you choose them; your rivals' show as <strong>?</strong> until the reveal. A question mark in an older row means a gesture you were not allowed to see: you were blind, or they were invisible. With many wizards the ledger runs sideways; the turn and your own two columns stay pinned while you slide across to the rest, and a row of names above it turns the ledger toward any wizard. Pressing a warning or choosing a target does the same. Each wizard keeps one colour in the ledger, the warnings and the rail, so a name met in one place is known in the next.</p>
</section> </section>
<section id="hands"> <section id="hands">
@@ -86,7 +86,7 @@
<img src="/guide/sheet.png" alt="The spell sheet, open at Fireball: its gestures, what it does, and buttons to plan it with the left or right hand." /> <img src="/guide/sheet.png" alt="The spell sheet, open at Fireball: its gestures, what it does, and buttons to plan it with the left or right hand." />
</figure> </figure>
</div> </div>
<p>The rail shows every wizard: fifteen marks of health, the enchantments on them and for how long, and the creatures that serve them with hits left and damage dealt. Your own creatures take orders beneath your hands each turn and keep attacking what they last attacked until told otherwise.</p> <p>The rail shows every wizard: fifteen marks of health, the enchantments on them and for how long, and the creatures that serve them with hits left and damage dealt. Past three wizards the rail lists the others on a line each, with their health, what matters about their condition, and whether they have written their turn; press a line to open it out. Your own creatures take orders beneath your hands each turn and keep attacking what they last attacked until told otherwise.</p>
<p>The spell sheet lists all forty spells. Gestures you have already made light up along each sequence. Open a spell to read it, then <strong>Plan with left hand</strong> or <strong>right hand</strong> to pin it and make its next gesture, which is how a duel begins when nothing is yet under way.</p> <p>The spell sheet lists all forty spells. Gestures you have already made light up along each sequence. Open a spell to read it, then <strong>Plan with left hand</strong> or <strong>right hand</strong> to pin it and make its next gesture, which is how a duel begins when nothing is yet under way.</p>
</section> </section>
+17 -2
View File
@@ -165,14 +165,15 @@
{:else} {:else}
<p class="eyebrow">room {roomId}</p> <p class="eyebrow">room {roomId}</p>
<h2>The table is laid</h2> <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>Send this link, or the code. Whoever opens it takes a seat; the table holds a dozen, and the duel begins when you say. Anyone who arrives once it 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> <p class="link-row"><code>{link}</code><button type="button" class="quiet" onclick={copyLink}>{copied ? 'Copied' : 'Copy link'}</button></p>
{/if} {/if}
<ul class="roster"> <ul class="roster">
{#each r.seats as s (s.id)} {#each r.seats as s (s.id)}
<li> <li>
<span class="seat-name">{s.name}</span> <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> <span class="muted">{s.id === r.host ? 'opened the table' : s.bot ? 'the bot' : 'seated'}{s.id === r.seat ? ', you' : ''}{#if s.bot && room.isHost}
<button type="button" class="unseat" title="Send this bot away" aria-label="Send {s.name} away" onclick={() => room?.unseat(s.id)}>×</button>{/if}</span>
</li> </li>
{/each} {/each}
<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> <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>
@@ -345,6 +346,20 @@
font-weight: 500; font-weight: 500;
} }
.unseat {
background: none;
border: 0;
color: var(--bone-faint);
font-size: 1.1rem;
padding: 0 0.3rem;
margin-left: 0.3rem;
line-height: 1;
}
.unseat:hover {
color: var(--blood);
}
.ways { .ways {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;