Duels between people: rooms, the hall, and a games ledger

The store plays any seat, locally against the bot or remotely through
the server. A room has a four-letter code that is also its invite link;
the hall opens or joins one and lists the seats this browser holds with
whose move it is. The ledger and summary show every seat, threats name
their wizard, and the board is one component shared by the hall and the
room page.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-09-22 17:07:51 -04:00
co-authored by Claude Fable 5.1
parent 8366c9d7ff
commit e1bda87987
10 changed files with 1430 additions and 524 deletions
+395
View File
@@ -0,0 +1,395 @@
<script lang="ts">
import Chronicle from './Chronicle.svelte';
import HandPicker from './HandPicker.svelte';
import Ledger from './Ledger.svelte';
import MobileBar from './MobileBar.svelte';
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 { GESTURE_SHORT, glyph } from '$lib/game/glyphs';
import { MAGIC_GESTURES } from '$lib/game/spells';
let { duel }: { duel: Duel } = $props();
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">
<section class="play">
<Ledger {duel} />
<TurnSummary {duel} />
{#if outcome}
<div class="verdict" id="verdict" class:won>
<h2>{won ? 'You win.' : outcome.winner === null ? 'A draw.' : 'You lose.'}</h2>
<p>{outcome.reason}</p>
{#if duel.remote}
<a class="reveal again" href="/">Back to the hall</a>
{:else}
<button type="button" class="reveal" onclick={() => duel.newGame()}>Duel again</button>
{/if}
</div>
{:else}
<section class="move" aria-labelledby="move-heading">
{#if duel.timeStopped}
<h2 id="move-heading">Time stands still. Take your extra turn.</h2>
<p class="threats"><span class="muted">{duel.foe.name} cannot move, see this, or resist anything you do. Last turn's enchantments do not bind you.</span></p>
{:else}
<h2 id="move-heading">Turn {duel.turnNumber}: write your gestures{#if duel.hasted}, twice{/if}</h2>
{/if}
{#if duel.threats.length && !duel.timeStopped}
<div class="threats">
{#if duel.threatsNow.length}
<p>Their hands could finish this turn:</p>
<ul>
{#each duel.threatsNow as t (t.hand + t.spell.id)}
{@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="tseq">{tokensText(t.done)} → <strong>{tokensText(t.remaining)}</strong></span>
<span class="tname">{t.spell.name}</span>
</button>
</li>
{/each}
</ul>
{/if}
{#if duel.threatsNext.length}
<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)}
{@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="tseq">{tokensText(t.done)} → {tokensText(t.remaining)}</span>
<span class="tname">{t.spell.name}</span>
</button>
</li>
{/each}
</ul>
</details>
{/if}
</div>
{:else if !duel.timeStopped}
<p class="threats muted">Nothing hostile is one gesture away.</p>
{/if}
<div class="hands">
<HandPicker {duel} hand="left" />
<HandPicker {duel} hand="right" />
</div>
<div class="prefs">
<label class="pref">
<input type="checkbox" checked={duel.showPlans} onchange={(e) => duel.setShowPlans(e.currentTarget.checked)} />
Show spells under way beneath each hand
</label>
<label class="pref">
<input type="checkbox" checked={duel.showLessons} onchange={(e) => duel.setShowLessons(e.currentTarget.checked)} />
Explain rules as they come up
</label>
</div>
{#if duel.hasted}
<p class="haste-note">You are hastened: a second pair of gestures follows the first, and both take effect together.</p>
<div class="hands">
<HandPicker {duel} hand="left" set="haste" />
<HandPicker {duel} hand="right" set="haste" />
</div>
{/if}
<div class="orders">
{#if duel.stabbing}
<label class="field">
<span>Stab</span>
<select bind:value={duel.stabTarget}>
{#each duel.others as id (id)}
<option value={id}>{duel.state.wizards[id].name}</option>
{/each}
{#each foeMonsters as m (m.id)}
<option value={m.id}>{m.name}</option>
{/each}
</select>
</label>
{/if}
{#each duel.yourMonsters as m (m.id)}
<label class="field">
<span>{m.name} attacks</span>
<select bind:value={duel.monsterOrders[m.id]}>
{#each duel.others as id (id)}
<option value={id}>{duel.state.wizards[id].name}</option>
{/each}
{#each foeMonsters as fm (fm.id)}
<option value={fm.id}>{fm.name}</option>
{/each}
</select>
</label>
{/each}
{#if duel.bankedSpell}
<label class="field">
<span>Release the banked {duel.bankedSpell.name.toLowerCase()}</span>
<select bind:value={duel.release}>
<option value="">not yet</option>
{#each duel.targetsFor(duel.bankedSpell) as t (t.id)}
<option value={t.id}>at {t.label}</option>
{/each}
</select>
</label>
{/if}
{#if duel.bankCandidates.length > 1}
<label class="field">
<span>Bank</span>
<select bind:value={duel.bankPick}>
{#each duel.bankCandidates as p (castKey(p.set, p.hand))}
<option value={castKey(p.set, p.hand)}>{p.completion.spell.name} ({p.hand}{p.set === 'haste' ? ', second pair' : ''})</option>
{/each}
</select>
</label>
{/if}
{#if duel.permanentCandidates.length > 1}
<label class="field">
<span>Make permanent</span>
<select bind:value={duel.permanentPick}>
{#each duel.permanentCandidates as p (castKey(p.set, p.hand))}
<option value={castKey(p.set, p.hand)}>{p.completion.spell.name} ({p.hand}{p.set === 'haste' ? ', second pair' : ''})</option>
{/each}
</select>
</label>
{/if}
{#if duel.youCharmedFoe}
<label class="field">
<span>{charmedName}'s charmed {charmedHandName} hand makes</span>
<select bind:value={duel.charmGesture}>
{#each MAGIC_GESTURES as g (g)}
<option value={g}>{glyph(g)} {GESTURE_SHORT[g]}</option>
{/each}
</select>
</label>
{/if}
</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>
{/if}
{#if duel.surrendering}
<p class="warning">Both palms at once is surrender. You will lose the duel.</p>
{/if}
{#if duel.conflict}
<p class="warning">Those two spells share a gesture. A gesture can finish only one spell; let one of them pass.</p>
{/if}
<button type="button" class="reveal" disabled={!duel.ready} onclick={() => duel.reveal()}>
{duel.moved ? 'Waiting' : duel.sending ? 'Sending' : duel.surrendering ? 'Surrender' : duel.timeStopped ? 'Act in the stopped moment' : 'Reveal gestures'}
</button>
</section>
{/if}
</section>
<aside class="rail">
<WizardStatus wizard={duel.you} monsters={duel.yourMonsters} you />
{#each duel.others as id (id)}
<WizardStatus wizard={duel.state.wizards[id]} monsters={duel.state.monsters.filter((m) => m.owner === id)} />
{/each}
<SpellSheet {duel} />
<Chronicle log={duel.state.log} />
</aside>
</main>
<MobileBar {duel} />
<style>
details.next summary {
cursor: pointer;
list-style: none;
}
details.next summary::before {
content: '\25B8 ';
color: var(--bone-faint);
}
details.next[open] summary::before {
content: '\25BE ';
}
.board {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
gap: 0 clamp(1.5rem, 4vw, 3.5rem);
padding: 0 var(--gutter);
max-width: 1280px;
margin: 0 auto;
}
.play {
min-width: 0;
}
.rail {
min-width: 0;
}
.move {
padding: 0.6rem 0 1.5rem;
}
.move h2 {
font-size: 1.3rem;
margin-bottom: 0.3rem;
}
.threats {
font-size: 0.9rem;
margin-bottom: 0.7rem;
}
.threats ul {
list-style: none;
margin: 0.15rem 0 0.4rem;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 0.3rem 0.5rem;
}
.threat {
background: var(--slate);
border: 1px solid transparent;
border-radius: 3px;
padding: 0.15rem 0.5rem;
display: inline-flex;
gap: 0.5rem;
align-items: baseline;
color: var(--bone-dim);
}
.threat.soon {
color: var(--bone);
}
.threat:hover,
.threat.active {
border-color: var(--frost);
}
.threat.active {
background: rgba(140, 200, 224, 0.12);
}
.thand {
font-style: italic;
font-size: 0.8rem;
}
.tseq {
letter-spacing: 0.04em;
}
.tseq strong {
font-weight: 600;
color: var(--ember);
}
.prefs {
display: flex;
flex-wrap: wrap;
gap: 0.3rem 1.5rem;
margin-top: 0.7rem;
}
.pref {
display: flex;
gap: 0.4rem;
align-items: center;
font-size: 0.85rem;
color: var(--bone-dim);
}
.hands {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.25rem 2rem;
}
.orders {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1.5rem;
margin-top: 0.9rem;
}
.warning {
color: var(--blood);
margin-top: 0.8rem;
}
.haste-note {
color: var(--frost);
font-size: 0.95rem;
margin: 1rem 0 0.6rem;
}
.move > .reveal {
margin-top: 0.8rem;
}
.verdict {
padding: 1.5rem 0;
}
.verdict h2 {
font-size: 2rem;
font-style: italic;
color: var(--blood);
}
.verdict.won h2 {
color: var(--ember);
}
.verdict p {
margin: 0.3rem 0 0.8rem;
color: var(--bone-dim);
}
/* Must match COMPACT_QUERY in duel.svelte.ts. */
@media (max-width: 860px) {
.board {
grid-template-columns: minmax(0, 1fr);
}
/* The sticky bar carries the reveal button on phones. */
.move > .reveal {
display: none;
}
.rail {
order: 2;
border-top: 1px solid var(--rule-strong);
margin-top: 1rem;
}
.hands {
grid-template-columns: 1fr;
}
}
.waiting {
color: var(--frost);
margin-top: 0.8rem;
}
.reveal.again {
display: inline-block;
text-decoration: none;
}
</style>