Files
waving-hands/src/lib/components/Ledger.svelte
T
Eric WagonerandClaude Fable 5.1 5de352db43 Credibility pass: one conflict check, one hostile list, one set of chrome
Duplicated logic and styling that had drifted apart is unified: the
gesture-sharing check, the hostile spell list, the enchanted test, the
plan limits, and the shared button, link, field and disabled styles.
Dead plumbing is gone: the always-true implemented flag, the unread
cast length, an unreachable guard, an impossible time-stop condition,
the unused sequence formatter and utility class, the template
placeholder. The counter-spell tests now put a counter-spell in front
of a spell, the test helpers live in one file, and the resolver's
sections are numbered in order. The deploy script's cache headers now
do what its comment says, and the README describes the screen rather
than listing features in the order they arrived.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 16:37:54 -04:00

252 lines
6.5 KiB
Svelte

<script lang="ts">
import { FOE, YOU, type Duel } from '$lib/game/duel.svelte';
import { glyph } from '$lib/game/glyphs';
import { SPELL_BY_ID, type Hand } from '$lib/game/spells';
import type { HistoryEntry, WizardId } from '$lib/game/state';
let { duel }: { duel: Duel } = $props();
let scroller: HTMLDivElement | undefined = $state();
interface Placed {
entry: HistoryEntry;
index: number;
hidden: boolean;
}
interface Row {
key: string;
label: string;
note?: string;
cells: Record<WizardId, Placed[]>;
}
function placed(who: WizardId, turn: number, phases: string[]): Placed[] {
const history = who === YOU ? duel.you.history : duel.foeVisible;
const raw = duel.state.wizards[who].history;
const out: Placed[] = [];
history.forEach((entry, index) => {
if (entry.turn === turn && phases.includes(entry.phase)) {
out.push({ entry, index, hidden: raw[index].hiddenFrom.includes(YOU) });
}
});
return out;
}
const rows = $derived.by(() => {
const out: Row[] = [];
for (let t = 0; t < duel.state.turn; t++) {
out.push({ key: `t${t}`, label: String(t + 1), cells: { A: placed(YOU, t, ['main', 'haste']), B: placed(FOE, t, ['main', 'haste']) } });
for (const who of [YOU, FOE] as WizardId[]) {
for (const p of placed(who, t, ['timestop'])) {
out.push({ key: `t${t}-${who}-${p.index}`, label: '', note: 'time stop', cells: { A: who === YOU ? [p] : [], B: who === FOE ? [p] : [] } });
}
}
}
return out;
});
const drafting = $derived(!duel.state.over);
const turns = $derived(duel.you.history.length + duel.foe.history.length);
function notesFor(who: WizardId, index: number, hand: Hand): string[] {
return duel.state.wizards[who].casts
.filter((c) => c.index === index && c.hand === hand)
.map((c) => SPELL_BY_ID[c.spellId].name);
}
$effect(() => {
void turns;
if (scroller) scroller.scrollTop = scroller.scrollHeight;
});
</script>
<div class="scroller" bind:this={scroller}>
<table class="ledger">
<thead>
<tr>
<th class="turn" scope="col"><span class="visually-hidden">Turn</span></th>
<th class="you" colspan="2" scope="colgroup">{duel.you.name} (you)</th>
<th class="foe" colspan="2" scope="colgroup">{duel.foe.name}</th>
</tr>
<tr class="hands">
<th></th>
<th scope="col">left</th>
<th scope="col">right</th>
<th scope="col">left</th>
<th scope="col">right</th>
</tr>
</thead>
<tbody>
{#each rows as row (row.key)}
<tr class:stopped={!!row.note}>
<td class="turn">{row.label}{#if row.note}<span class="rownote">{row.note}</span>{/if}</td>
{#each [YOU, FOE] as const as who (who)}
{#each ['left', 'right'] as const as hand (hand)}
<td class="cell" class:you={who === YOU} class:foe={who === FOE}>
{#each row.cells[who] as p (p.index)}
{@const lit = who === FOE && duel.highlight?.hand === hand && p.index >= duel.highlight.from && p.index <= duel.highlight.to}
{@const notes = notesFor(who, p.index, hand)}
{@const other = hand === 'left' ? p.entry.right : p.entry.left}
<span class="entry" class:cast={notes.length > 0} class:hidden={p.hidden} class:haste={p.entry.phase === 'haste'} class:lit>
<span class="letter">{p.hidden ? '?' : glyph(p.entry[hand], other)}</span>
{#if notes.length}<span class="note">{notes.join(', ')}</span>{/if}
</span>
{/each}
</td>
{/each}
{/each}
</tr>
{/each}
{#if drafting}
<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">
{#each duel.activeSets as set (set)}
{@const undecided = duel.chosenGesture(set, hand) === null || duel.charmedHand === hand}
{@const cast = duel.chosen[set][hand]}
<span class="entry" class:cast={!!cast} class:haste={set === 'haste'}>
<span class="letter" class:placeholder={undecided}>{duel.drawn(set, hand)}</span>
{#if cast && duel.sets[set][hand].spellId}<span class="note">{cast.spell.name}</span>{/if}
</span>
{/each}
</td>
{/each}
<td class="cell foe">{#if !duel.timeStopped}<span class="letter placeholder">?</span>{/if}</td>
<td class="cell foe">{#if !duel.timeStopped}<span class="letter placeholder">?</span>{/if}</td>
</tr>
{/if}
</tbody>
</table>
</div>
<style>
.scroller {
max-height: clamp(170px, 30vh, 400px);
overflow-y: auto;
border-top: 1px solid var(--rule-strong);
border-bottom: 1px solid var(--rule-strong);
}
.ledger {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
thead th {
position: sticky;
top: 0;
background: var(--slate-deep);
font-weight: 500;
text-align: center;
padding: 0.5rem 0 0.15rem;
font-size: 1.05rem;
z-index: 1;
}
thead th.you,
thead th.foe {
font-style: italic;
font-size: 1.15rem;
}
thead tr.hands th {
top: 2.2rem;
font-size: 0.8rem;
font-weight: 400;
color: var(--bone-dim);
padding: 0 0 0.35rem;
border-bottom: 1px solid var(--rule-strong);
}
th.turn,
td.turn {
width: 2.9rem;
color: var(--bone-faint);
text-align: right;
padding-right: 0.5rem;
font-size: 0.85rem;
font-variant-numeric: tabular-nums;
vertical-align: top;
padding-top: 0.6rem;
}
.rownote {
display: block;
font-size: 0.6rem;
font-style: italic;
line-height: 1.1;
color: var(--frost);
}
td.cell {
text-align: center;
vertical-align: top;
padding: 0.35rem 0.2rem 0.3rem;
border-bottom: 1px solid var(--rule);
line-height: 1.1;
}
tr.stopped td.cell {
background: rgba(140, 200, 224, 0.06);
}
td.cell.you:nth-child(3) {
border-right: 1px solid var(--rule-strong);
}
.entry {
display: block;
}
.entry.haste .letter {
font-size: clamp(1.1rem, 2.4vw, 1.4rem);
color: var(--frost);
}
.letter {
display: block;
font-size: clamp(1.6rem, 3.5vw, 2.1rem);
color: var(--bone);
}
.entry.cast .letter {
color: var(--ember);
}
.entry.hidden .letter {
color: var(--bone-faint);
}
.entry.lit {
background: rgba(140, 200, 224, 0.18);
outline: 1px solid var(--frost);
border-radius: 3px;
}
.entry.lit .letter {
color: var(--frost);
}
.placeholder {
color: var(--bone-faint);
}
.note {
display: block;
font-size: 0.72rem;
font-style: italic;
color: var(--ember);
line-height: 1.15;
margin-top: 0.15rem;
}
tr.draft td.cell {
background: var(--slate);
border-bottom: 2px solid var(--ember);
}
</style>