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
+37 -11
View File
@@ -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);
</script>
<main class="board">
@@ -58,11 +62,11 @@
{#if duel.threatsNow.length}
<p>Their hands could finish this turn:</p>
<ul>
{#each duel.threatsNow as t (t.hand + t.spell.id)}
{#each duel.threatsNow as t (duel.threatKey(t))}
{@const active = duel.isHighlighted(t)}
<li>
<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="tname">{t.spell.name}</span>
</button>
@@ -74,11 +78,11 @@
<details class="next" open={!duel.compact}>
<summary class="muted">Two gestures away ({duel.threatsNext.length})</summary>
<ul>
{#each duel.threatsNext as t (t.hand + t.spell.id)}
{#each duel.threatsNext as t (duel.threatKey(t))}
{@const active = duel.isHighlighted(t)}
<li>
<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="tname">{t.spell.name}</span>
</button>
@@ -118,9 +122,9 @@
{#if duel.stabbing}
<label class="field">
<span>Stab</span>
<select bind:value={duel.stabTarget}>
<select bind:value={duel.stabTarget} onchange={() => duel.focusOn(duel.stabTarget)}>
{#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 foeMonsters as m (m.id)}
<option value={m.id}>{m.name}</option>
@@ -131,9 +135,9 @@
{#each duel.yourMonsters as m (m.id)}
<label class="field">
<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)}
<option value={id}>{duel.state.wizards[id].name}</option>
<option value={id}>{crowded ? duel.targetLabel(id) : duel.state.wizards[id].name}</option>
{/each}
{#each foeMonsters as fm (fm.id)}
<option value={fm.id}>{fm.name}</option>
@@ -187,6 +191,9 @@
{#if duel.remote?.error}
<p class="warning">{duel.remote.error}</p>
{/if}
{#if duel.intentText && !duel.moved}
<p class="intent">{duel.intentText}</p>
{/if}
{#if duel.moved}
<p class="waiting">Your move is in. Waiting for {duel.awaitingText}.</p>
{/if}
@@ -209,9 +216,13 @@
{#if !duel.spectating}
<WizardStatus wizard={duel.you} monsters={duel.yourMonsters} you />
{/if}
{#each duel.others as id (id)}
<WizardStatus wizard={duel.state.wizards[id]} monsters={duel.state.monsters.filter((m) => m.owner === id)} />
{/each}
{#if crowded}
<Roster {duel} />
{: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} />
<SpellSheet {duel} />
<Chronicle log={duel.state.log} />
@@ -252,6 +263,21 @@
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 {
padding: 0.6rem 0 1.5rem;
}