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
+38 -20
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { FOE, YOU, type Duel } from '$lib/game/duel.svelte';
import 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';
@@ -21,13 +21,16 @@
cells: Record<WizardId, Placed[]>;
}
/** You first, then the others in table order. */
const columns = $derived<WizardId[]>([duel.me, ...duel.others]);
function placed(who: WizardId, turn: number, phases: string[]): Placed[] {
const history = who === YOU ? duel.you.history : duel.foeVisible;
const history = who === duel.me ? duel.you.history : duel.visible(who);
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) });
out.push({ entry, index, hidden: raw[index].hiddenFrom.includes(duel.me) });
}
});
return out;
@@ -36,10 +39,14 @@
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[]) {
const cells: Record<WizardId, Placed[]> = {};
for (const who of columns) cells[who] = placed(who, t, ['main', 'haste']);
out.push({ key: `t${t}`, label: String(t + 1), cells });
for (const who of columns) {
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] : [] } });
const only: Record<WizardId, Placed[]> = {};
for (const c of columns) only[c] = c === who ? [p] : [];
out.push({ key: `t${t}-${who}-${p.index}`, label: '', note: 'time stop', cells: only });
}
}
}
@@ -47,7 +54,7 @@
});
const drafting = $derived(!duel.state.over);
const turns = $derived(duel.you.history.length + duel.foe.history.length);
const turns = $derived(columns.reduce((n, id) => n + duel.state.wizards[id].history.length, 0));
function notesFor(who: WizardId, index: number, hand: Hand): string[] {
return duel.state.wizards[who].casts
@@ -55,6 +62,11 @@
.map((c) => SPELL_BY_ID[c.spellId].name);
}
function lit(who: WizardId, hand: Hand, index: number): boolean {
const h = duel.highlight;
return !!h && h.who === who && h.hand === hand && index >= h.from && index <= h.to;
}
$effect(() => {
void turns;
if (scroller) scroller.scrollTop = scroller.scrollHeight;
@@ -66,29 +78,29 @@
<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>
{#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>
{/each}
</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>
{#each columns as who (who)}
<th scope="col">left</th>
<th scope="col">right</th>
{/each}
</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 columns as who (who)}
{#each ['left', 'right'] as const as hand (hand)}
<td class="cell" class:you={who === YOU} class:foe={who === FOE}>
<td class="cell" class:you={who === duel.me} class:foe={who !== duel.me}>
{#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="entry" class:cast={notes.length > 0} class:hidden={p.hidden} class:haste={p.entry.phase === 'haste'} class:lit={lit(who, hand, p.index)}>
<span class="letter">{p.hidden ? '?' : glyph(p.entry[hand], other)}</span>
{#if notes.length}<span class="note">{notes.join(', ')}</span>{/if}
</span>
@@ -113,8 +125,10 @@
{/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>
{#each duel.others as who (who)}
<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>
{/each}
</tr>
{/if}
</tbody>
@@ -193,10 +207,14 @@
background: rgba(140, 200, 224, 0.06);
}
td.cell.you:nth-child(3) {
td.cell:nth-child(odd) {
border-right: 1px solid var(--rule-strong);
}
td.cell:last-child {
border-right: 0;
}
.entry {
display: block;
}