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>
+274
View File
@@ -0,0 +1,274 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { allSeats, api, forgetSeat } from '$lib/game/client';
import { Duel } from '$lib/game/duel.svelte';
import type { RoomView } from '$lib/game/view';
let { name }: { name: string } = $props();
let code = $state('');
let busy = $state(false);
let error = $state('');
let seats = $state(allSeats());
let games = $state<Record<string, RoomView | null>>({});
// Each held seat is looked up once, so the ledger can say whose move it is.
$effect(() => {
for (const held of seats) {
if (held.roomId in games) continue;
games[held.roomId] = null;
api.view(held.roomId, held.token)
.then((v) => {
games[held.roomId] = v;
})
.catch(() => {
games[held.roomId] = null;
});
}
});
async function create() {
busy = true;
error = '';
try {
const remote = await Duel.createRoom(name, 2);
await goto(`/join/${remote.roomId}`);
} catch (e) {
error = e instanceof Error ? e.message : 'The duel could not be opened.';
} finally {
busy = false;
}
}
function join(e: SubmitEvent) {
e.preventDefault();
const c = code.trim().toUpperCase();
if (c) void goto(`/join/${c}`);
}
function forget(roomId: string) {
forgetSeat(roomId);
seats = allSeats();
}
function status(held: { roomId: string; seat: string }): string {
const g = games[held.roomId];
if (g === undefined) return 'looking';
if (g === null) return 'unreachable';
const others = g.seats.filter((s) => s.id !== held.seat).map((s) => s.name);
if (!g.state) return `waiting to start${g.seats.length ? ` · ${g.seats.map((s) => s.name).join(', ')}` : ''}`;
if (g.state.over) {
if (g.state.over.winner === held.seat) return 'you won';
if (g.state.over.winner === null) return 'a draw';
return `${g.state.wizards[g.state.over.winner].name} won`;
}
const turn = `turn ${g.state.turn + 1}`;
if (g.waitingOn.includes(held.seat)) return `your move · ${turn} · against ${others.join(', ')}`;
return `waiting on ${g.waitingOn.map((id) => g.state!.wizards[id].name).join(', ')} · ${turn}`;
}
function yourMove(held: { roomId: string; seat: string }): boolean {
const g = games[held.roomId];
return !!g?.state && !g.state.over && g.waitingOn.includes(held.seat);
}
</script>
<section class="hall" id="hall">
<h2>Duel a friend</h2>
<p class="pitch">A duel between people is played by turns, whenever each of you has a moment. Open one and send the link, or join with a code.</p>
<div class="ways">
<button type="button" class="reveal small" disabled={busy} onclick={create}>Create a duel as {name}</button>
<form class="joinform" onsubmit={join}>
<span class="or">or join one</span>
<input type="text" class="code" bind:value={code} maxlength="4" placeholder="CODE" autocapitalize="characters" autocomplete="off" aria-label="room code" />
<button type="submit" class="quiet" disabled={code.trim().length < 4}>Join</button>
</form>
</div>
{#if error}<p class="warning">{error}</p>{/if}
{#if seats.length}
<div class="ledger">
<div class="ledger-head">your duels</div>
{#each seats as held (held.roomId)}
<div class="ledger-row" class:your-move={yourMove(held)}>
<a class="ledger-resume" href={`/join/${held.roomId}`}>
<span class="ledger-code">{held.roomId}</span>
<span class="ledger-info">{status(held)}</span>
</a>
<button type="button" class="ledger-forget" title="forget this duel" onclick={() => forget(held.roomId)}>×</button>
</div>
{/each}
</div>
{/if}
<details class="about">
<summary>about this game — a labor of love</summary>
<div class="about-body">
<p>Waving Hands is Richard Bartle's game from 1977, played with pencil, paper and two hands. Each wizard writes down a gesture for each hand, both reveal at once, and the right run of gestures on one hand is a spell. It was later known as Spellbinder and Spellcaster, and Andrew Plotkin's <em>Spellcast</em> brought it to university X terminals in 1993, which is where the maker of this page first met it.</p>
<p>This version keeps the game as written: all forty spells, the same simultaneous resolution, the same ledger of letters. The rules text it follows is the fanzine transcription, kept in full on the <a href="/rules">rules page</a>. The bot is a heuristic that reads your gestures as you would read its; the hand silhouettes on the buttons are adapted from Plotkin's bitmaps, with his notice retained.</p>
<p>It is free, keeps no accounts, and stores your single-player duel in your own browser. Duels between people live on a small server as an append-only ledger of moves, so a game can be replayed from its first gesture.</p>
</div>
</details>
</section>
<style>
.hall {
max-width: 1280px;
margin: 0 auto;
padding: 1.5rem var(--gutter) 0.5rem;
border-top: 1px solid var(--rule);
}
h2 {
font-size: 1.4rem;
margin-bottom: 0.3rem;
}
.pitch {
max-width: 38em;
color: var(--bone-dim);
margin-bottom: 0.9rem;
}
.ways {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6rem 1.25rem;
}
.reveal.small {
padding: 0.45rem 1rem;
font-size: 1rem;
}
.joinform {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.or {
color: var(--bone-dim);
font-style: italic;
}
.code {
width: 6.5em;
text-align: center;
text-transform: uppercase;
letter-spacing: 0.15em;
font-weight: 600;
}
.quiet {
background: none;
border: 1px solid var(--rule-strong);
border-radius: 4px;
padding: 0.4rem 0.9rem;
color: var(--bone);
}
.quiet:hover:not(:disabled) {
border-color: var(--bone);
}
.warning {
color: var(--blood);
margin-top: 0.6rem;
}
/* the games ledger */
.ledger {
margin-top: 1.4rem;
border-top: 1px solid var(--rule-strong);
padding-top: 0.5rem;
max-width: 40em;
}
.ledger-head {
font-style: italic;
color: var(--bone-dim);
margin-bottom: 0.3rem;
}
.ledger-row {
display: flex;
align-items: center;
gap: 0.4rem;
border-radius: 4px;
padding: 0.15rem 0.3rem;
}
.ledger-row.your-move {
background: rgba(240, 165, 58, 0.12);
}
.ledger-resume {
flex: 1;
display: flex;
align-items: baseline;
gap: 0.7rem;
padding: 0.3rem 0.2rem;
color: var(--bone);
text-decoration: none;
font-size: 0.95rem;
}
.ledger-resume:hover .ledger-code {
text-decoration: underline;
}
.ledger-code {
font-weight: 600;
letter-spacing: 0.12em;
}
.ledger-info {
color: var(--bone-dim);
}
.ledger-row.your-move .ledger-info {
color: var(--ember);
}
.ledger-forget {
background: none;
border: 0;
color: var(--bone-faint);
font-size: 1.05rem;
padding: 0 0.3rem;
}
.ledger-forget:hover {
color: var(--blood);
}
.about {
margin-top: 1.4rem;
}
.about summary {
cursor: pointer;
font-style: italic;
color: var(--frost);
}
.about-body {
max-width: 40em;
color: var(--bone-dim);
padding: 0.6rem 0 0.4rem;
}
.about-body p + p {
margin-top: 0.6rem;
}
.about-body em {
color: var(--bone);
}
.about-body a {
color: var(--frost);
}
</style>
+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;
}
+3 -3
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';
let { duel }: { duel: Duel } = $props();
@@ -14,10 +14,10 @@
<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>
<dl>
{#each [YOU, FOE] as const as who (who)}
{#each duel.state.seats as who (who)}
{@const w = summary.wizards[who]}
<div>
<dt>{who === YOU ? 'You' : duel.foe.name}</dt>
<dt>{who === duel.me ? 'You' : duel.state.wizards[who].name}</dt>
<dd>
{#if w.effects.length === 0}
<span class="muted">untouched</span>
+111
View File
@@ -0,0 +1,111 @@
// The browser's side of the duel server: a few JSON calls, and the seats this
// browser holds, remembered so a shared link never has to carry a token.
import type { TurnInput, WizardId } from './state';
import type { RoomView } from './view';
export interface HeldSeat {
seat: WizardId;
token: string;
}
const SEATS_KEY = 'wh:seats';
function heldSeats(): Record<string, HeldSeat> {
try {
return JSON.parse(localStorage.getItem(SEATS_KEY) ?? '{}') as Record<string, HeldSeat>;
} catch {
return {};
}
}
export function seatFor(roomId: string): HeldSeat | null {
return heldSeats()[roomId] ?? null;
}
/** Every seat this browser holds, newest last. */
export function allSeats(): { roomId: string; seat: WizardId; token: string }[] {
return Object.entries(heldSeats()).map(([roomId, h]) => ({ roomId, ...h }));
}
export function forgetSeat(roomId: string): void {
try {
const seats = heldSeats();
delete seats[roomId];
localStorage.setItem(SEATS_KEY, JSON.stringify(seats));
} catch {
// Nothing to forget without storage.
}
}
export function rememberSeat(roomId: string, held: HeldSeat): void {
try {
localStorage.setItem(SEATS_KEY, JSON.stringify({ ...heldSeats(), [roomId]: held }));
} catch {
// Without storage the seat lasts for this page only.
}
}
export class ServerError extends Error {
constructor(
message: string,
public status: number
) {
super(message);
}
}
async function call<T>(method: string, path: string, body?: unknown): Promise<T> {
const res = await fetch(path, {
method,
headers: body ? { 'content-type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined
});
const data = (await res.json().catch(() => ({}))) as { error?: string };
if (!res.ok) throw new ServerError(data.error ?? `The server answered ${res.status}.`, res.status);
return data as T;
}
type Seated = { seat: WizardId; token: string; view: RoomView };
export const api = {
create: (name: string, size: number) => call<Seated>('POST', '/api/rooms', { name, size }),
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 }),
view: (roomId: string, token: string) => call<RoomView>('GET', `/api/rooms/${roomId}?token=${encodeURIComponent(token)}`),
turn: (roomId: string, token: string, input: TurnInput) => call<RoomView>('POST', `/api/rooms/${roomId}/turn`, { token, input })
};
/** A socket that only ever says "fetch the view again"; reconnects on its own. */
export function watchRoom(roomId: string, token: string, onUpdate: () => void, onStatus: (open: boolean) => void): () => void {
let socket: WebSocket | null = null;
let closed = false;
let delay = 1000;
const open = () => {
if (closed) return;
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
socket = new WebSocket(`${protocol}://${location.host}/ws?room=${encodeURIComponent(roomId)}&token=${encodeURIComponent(token)}`);
socket.onopen = () => {
delay = 1000;
onStatus(true);
onUpdate();
};
socket.onmessage = (e) => {
try {
if (JSON.parse(String(e.data)).type === 'update') onUpdate();
} catch {
// Not a message this client understands.
}
};
socket.onclose = () => {
onStatus(false);
if (!closed) setTimeout(open, delay);
delay = Math.min(delay * 2, 30000);
};
};
open();
return () => {
closed = true;
socket?.close();
};
}
+216 -56
View File
@@ -1,7 +1,10 @@
// Reactive wrapper around the engine for a duel of one person against the bot.
// Reactive wrapper around the engine for the wizard at this keyboard. A local
// duel resolves against the bot in this browser; a remote one sends each turn
// to the server and shows whatever view comes back.
import { tick } from 'svelte';
import { chooseBotTurn } from './bot';
import { api, rememberSeat, watchRoom, type HeldSeat } from './client';
import {
cellsOverlap,
completableIn,
@@ -31,6 +34,7 @@ import {
import {
NOWHERE,
createGame,
enemyOf,
visibleHistory,
type CastChoice,
type GameState,
@@ -39,9 +43,11 @@ import {
type TurnInput,
type WizardId
} from './state';
import type { RoomView } from './view';
export const YOU: WizardId = 'A';
export const FOE: WizardId = 'B';
/** The seats of a duel played in this browser against the bot. */
const LOCAL_ME: WizardId = 'A';
const LOCAL_BOT: WizardId = 'B';
/** Narrow layouts: shorter lists and a sticky bar. The stylesheets in +page, MobileBar and TurnSummary use the same width. */
export const COMPACT_QUERY = '(max-width: 860px)';
@@ -113,8 +119,9 @@ export interface Plan {
pinned: boolean;
}
/** A spell the opponent's hand could finish soon, with the gestures that say so. */
/** A spell an opponent's hand could finish soon, with the gestures that say so. */
export interface Threat {
who: WizardId;
hand: Hand;
spell: Spell;
done: Token[];
@@ -126,6 +133,7 @@ export interface Threat {
}
export interface Highlight {
who: WizardId;
hand: Hand;
from: number;
to: number;
@@ -160,6 +168,7 @@ function loadSaved(): SavedDuel | null {
const saved = JSON.parse(raw) as SavedDuel;
if (saved.v !== SAVE_VERSION || !saved.state?.wizards?.A || !saved.state?.wizards?.B) return null;
// Saves at this version may lack fields that came later; the engine expects them present.
saved.state.seats ??= Object.keys(saved.state.wizards);
saved.state.lessonsShown ??= [];
if (saved.state.lastTurn) {
saved.state.lastTurn.events ??= [];
@@ -188,10 +197,24 @@ function readPreference(key: string, fallback: boolean): boolean {
}
}
/** A seat at a duel the server is keeping. */
export interface Remote extends HeldSeat {
roomId: string;
size: number;
seats: RoomView['seats'];
waitingOn: WizardId[];
started: boolean;
connected: boolean;
error: string;
}
export class Duel {
state = $state<GameState>(createGame(pickNames()));
/** The seat this browser plays. */
me = $state<WizardId>(LOCAL_ME);
remote = $state<Remote | null>(null);
sets = $state<Record<SetName, Record<Hand, HandDraft>>>(blankSets());
stabTarget = $state<TargetId>(FOE);
stabTarget = $state<TargetId | ''>('');
monsterOrders = $state<Record<string, TargetId>>({});
/** Narrow screens get shorter lists and a sticky bar; the page sets this from a media query. */
compact = $state(false);
@@ -209,16 +232,22 @@ export class Duel {
sheetOpen = $state(false);
/** Opponent gestures to light up in the ledger. */
highlight = $state<Highlight | null>(null);
/** A turn is on its way to the server. */
sending = $state(false);
constructor(initial?: GameState) {
constructor(initial?: GameState | Remote) {
const saved = typeof localStorage === 'undefined' ? null : loadSaved();
if (initial) {
if (initial && 'roomId' in initial) {
this.remote = initial;
this.me = initial.seat;
this.state = createGame({ [initial.seat]: 'You' });
} else if (initial) {
this.state = initial;
} else if (saved) {
this.state = saved.state;
this.pins = saved.pins ?? { left: null, right: null };
this.sets = saved.sets ?? blankSets();
this.stabTarget = saved.stabTarget ?? FOE;
this.stabTarget = saved.stabTarget ?? '';
this.monsterOrders = saved.monsterOrders ?? {};
this.release = saved.release ?? '';
}
@@ -243,15 +272,17 @@ export class Duel {
} catch {
// The name still applies to this duel.
}
if (this.remote) return;
const next = name || pickNames('').A;
if (next.toLowerCase() === this.foe.name.toLowerCase()) {
this.state.wizards[FOE].name = pickNames(next).B;
this.state.wizards[LOCAL_BOT].name = pickNames(next).B;
}
this.state.wizards[YOU].name = next;
this.state.wizards[LOCAL_ME].name = next;
}
/** Write the duel and the half-written turn to local storage. Reads everything it saves, so an effect can track it. */
persist(): void {
if (this.remote) return;
const bundle: SavedDuel = {
v: SAVE_VERSION,
state: $state.snapshot(this.state),
@@ -271,11 +302,21 @@ export class Duel {
/** A duel worth asking about before it is thrown away. */
inProgress = $derived(this.state.turn > 0 && !this.state.over);
you = $derived(this.state.wizards[YOU]);
foe = $derived(this.state.wizards[FOE]);
you = $derived(this.state.wizards[this.me]);
/** The opponent spells go at by default: the first still standing. */
foeId = $derived(enemyOf(this.state, this.me));
foe = $derived(this.state.wizards[this.foeId]);
/** Every other seat, in table order. */
others = $derived(this.state.seats.filter((id) => id !== this.me));
turnNumber = $derived(this.state.turn + 1);
/** Your extra turn under time stop: nobody else moves and last turn's enchantments do not bind. */
timeStopped = $derived(this.state.timeStops[0] === YOU);
timeStopped = $derived(this.state.timeStops[0] === this.me);
/** Your move is in and the turn waits on someone else. */
moved = $derived(!!this.remote && this.remote.started && !this.state.over && !this.remote.waitingOn.includes(this.me));
/** Names of the seats the turn is waiting on. */
awaiting = $derived(
this.remote ? this.remote.waitingOn.filter((id) => id !== this.me).map((id) => this.state.wizards[id]?.name ?? id) : []
);
hasted = $derived(this.you.hasteTurns > 0 && !this.timeStopped);
activeSets = $derived<SetName[]>(this.hasted ? ['main', 'haste'] : ['main']);
@@ -295,8 +336,9 @@ export class Duel {
return set === 'haste' ? this.forcedSecond[hand] : this.forced[hand];
}
charmedHand = $derived<Hand | undefined>(this.timeStopped ? undefined : this.you.constraints.charmed?.hand);
/** True when the bot's hand is yours to command this turn. */
youCharmedFoe = $derived(this.foe.constraints.charmed?.by === YOU && !this.timeStopped);
/** The opponent whose hand is yours to command this turn, if any. */
charmedFoe = $derived(this.others.find((id) => this.state.wizards[id].constraints.charmed?.by === this.me));
youCharmedFoe = $derived(this.charmedFoe !== undefined && !this.timeStopped);
/** The gesture a hand will make: forced by an enchantment, or chosen, or nothing yet. */
chosenGesture(set: SetName, hand: Hand): Gesture | null {
@@ -392,20 +434,28 @@ export class Duel {
}
summary = $derived(this.state.lastTurn);
foeVisible = $derived(visibleHistory(this.state, YOU, FOE));
/** Hostile spells the opponent could finish this turn or next, with the evidence. */
/** Each opponent's gestures as you are allowed to see them. */
visible(id: WizardId): HistoryEntry[] {
return visibleHistory(this.state, this.me, id);
}
/** Hostile spells any opponent could finish this turn or next, with the evidence. */
threats = $derived.by(() => {
const out: Threat[] = [];
for (const hand of HANDS) {
const seq = handSequence(this.foeVisible, hand);
for (const turnsAway of [1, 2]) {
for (const c of completableIn(seq, this.foe.sequenceStart, turnsAway)) {
if (!HOSTILE_SPELLS.includes(c.spell.id)) continue;
if (c.spell.id === 'lightning_bolt_quick' && this.foe.usedQuickLightning) continue;
const tokens = c.spell.tokens[c.seqIndex];
const done = tokens.slice(0, tokens.length - turnsAway);
if (out.some((t) => t.hand === hand && t.spell.id === c.spell.id)) continue;
out.push({ hand, spell: c.spell, done, remaining: c.remaining, from: seq.length - done.length, to: seq.length - 1, soon: turnsAway === 1 });
for (const who of this.others) {
const w = this.state.wizards[who];
if (!w.alive || w.surrendered) continue;
const hist = this.visible(who);
for (const hand of HANDS) {
const seq = handSequence(hist, hand);
for (const turnsAway of [1, 2]) {
for (const c of completableIn(seq, w.sequenceStart, turnsAway)) {
if (!HOSTILE_SPELLS.includes(c.spell.id)) continue;
if (c.spell.id === 'lightning_bolt_quick' && w.usedQuickLightning) continue;
const tokens = c.spell.tokens[c.seqIndex];
const done = tokens.slice(0, tokens.length - turnsAway);
if (out.some((t) => t.who === who && t.hand === hand && t.spell.id === c.spell.id)) continue;
out.push({ who, hand, spell: c.spell, done, remaining: c.remaining, from: seq.length - done.length, to: seq.length - 1, soon: turnsAway === 1 });
}
}
}
}
@@ -415,8 +465,15 @@ export class Duel {
threatsNext = $derived(this.threats.filter((t) => !t.soon).slice(0, 6));
toggleHighlight(t: Threat): void {
const same = this.highlight && this.highlight.hand === t.hand && this.highlight.from === t.from && this.highlight.to === t.to;
this.highlight = same ? null : { hand: t.hand, from: t.from, to: t.to };
const h = this.highlight;
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 };
}
/** Whether a threat is the one lit up in the ledger. */
isHighlighted(t: Threat): boolean {
const h = this.highlight;
return !!h && h.who === t.who && h.hand === t.hand && h.from === t.from && h.to === t.to;
}
/** Spells each hand is part-way through, based on the gestures already on the ledger. */
@@ -506,9 +563,11 @@ export class Duel {
surrendering = $derived(this.activeSets.some((set) => this.gestureFor(set, 'left') === 'P' && this.gestureFor(set, 'right') === 'P'));
stabbing = $derived(this.activeSets.some((set) => HANDS.some((h) => this.gestureFor(set, h) === '>')));
yourMonsters = $derived(this.state.monsters.filter((m) => m.owner === YOU));
yourMonsters = $derived(this.state.monsters.filter((m) => m.owner === this.me));
ready = $derived(
!this.state.over &&
!this.moved &&
!this.sending &&
this.activeSets.every((set) =>
HANDS.every((h) => this.forced[h] || this.charmedHand === h || this.sets[set][h].gesture !== null)
) &&
@@ -518,14 +577,14 @@ export class Duel {
/** A delayed effect is waiting for a spell to bank, counting one cast this turn. */
bankingActive = $derived(
!this.you.banked &&
(this.you.delayedTurns > 0 || this.planned.some((p) => p.completion.spell.id === 'delayed_effect' && (p.draft.target || YOU) === YOU))
(this.you.delayedTurns > 0 || this.planned.some((p) => p.completion.spell.id === 'delayed_effect' && (p.draft.target || this.me) === this.me))
);
bankCandidates = $derived(this.bankingActive ? this.planned.filter((p) => p.completion.spell.id !== 'delayed_effect') : []);
willBank = $derived(
this.bankCandidates.find((p) => castKey(p.set, p.hand) === this.bankPick) ?? this.bankCandidates[0]
);
permanencyActive = $derived(
this.you.permanencyTurns > 0 || this.planned.some((p) => p.completion.spell.id === 'permanency' && (p.draft.target || YOU) === YOU)
this.you.permanencyTurns > 0 || this.planned.some((p) => p.completion.spell.id === 'permanency' && (p.draft.target || this.me) === this.me)
);
permanentCandidates = $derived(
this.permanencyActive
@@ -539,17 +598,15 @@ export class Duel {
/** Where a spell goes unless the player says otherwise. */
defaultTarget(spell: Spell | undefined): TargetId | '' {
return spell ? likelyTarget(this.state, YOU, spell) : '';
return spell ? likelyTarget(this.state, this.me, spell) : '';
}
/** Every possible subject, the likeliest first, creatures marked by whose they are. */
targetsFor(spell: Spell | undefined): { id: TargetId; label: string }[] {
const list: { id: TargetId; label: string }[] = [
{ id: YOU, label: 'yourself' },
{ id: FOE, label: this.foe.name }
];
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 });
for (const m of this.state.monsters) {
list.push({ id: m.id, label: `${m.name} (${m.owner === YOU ? 'yours' : `${this.foe.name}'s`})` });
list.push({ id: m.id, label: `${m.name} (${m.owner === this.me ? 'yours' : `${this.state.wizards[m.owner].name}'s`})` });
}
const likely = this.defaultTarget(spell);
list.sort((a, b) => Number(b.id === likely) - Number(a.id === likely));
@@ -588,21 +645,21 @@ export class Duel {
};
if (spell.id === 'summon_elemental') cast.elemental = p.draft.elemental;
if (spell.id === 'paralysis' || spell.id === 'charm_person') cast.chosenHand = p.draft.chosenHand;
if (spell.category === 'summons') cast.monsterTarget = FOE;
if (spell.category === 'summons') cast.monsterTarget = this.foeId;
if (p === this.willBank) cast.bank = true;
if (p === this.willExtend) cast.permanent = true;
return cast;
}
reveal(): void {
if (!this.ready) return;
/** The move as the engine wants it. */
private input(): TurnInput {
const orders: Record<string, TargetId> = {};
for (const m of this.yourMonsters) orders[m.id] = this.monsterOrders[m.id] ?? FOE;
for (const m of this.yourMonsters) orders[m.id] = this.monsterOrders[m.id] ?? this.foeId;
const yours: TurnInput = {
left: this.sets.main.left.gesture ?? '-',
right: this.sets.main.right.gesture ?? '-',
casts: this.planned.filter((p) => p.set === 'main').map((p) => this.castFor(p)),
stabTarget: this.stabTarget,
stabTarget: this.stabTarget || this.foeId,
monsterOrders: orders
};
if (this.hasted) {
@@ -610,23 +667,87 @@ export class Duel {
left: this.sets.haste.left.gesture ?? '-',
right: this.sets.haste.right.gesture ?? '-',
casts: this.planned.filter((p) => p.set === 'haste').map((p) => this.castFor(p)),
stabTarget: this.stabTarget
stabTarget: this.stabTarget || this.foeId
};
}
if (this.release && this.you.banked) yours.release = { target: this.release, monsterTarget: FOE };
if (this.release && this.you.banked) yours.release = { target: this.release, monsterTarget: this.foeId };
if (this.youCharmedFoe) yours.charmGesture = this.charmGesture;
return yours;
}
reveal(): void {
if (!this.ready) return;
const yours = this.input();
if (this.remote) {
void this.send(yours);
return;
}
// The engine clones plain data; hand it a snapshot rather than the reactive proxy.
let plain = $state.snapshot(this.state);
const theirs = chooseBotTurn(plain, FOE);
plain = resolveTurn(plain, { A: yours, B: theirs });
const theirs = chooseBotTurn(plain, LOCAL_BOT);
plain = resolveTurn(plain, { [LOCAL_ME]: yours, [LOCAL_BOT]: theirs });
// The bot takes any stopped moments it is owed straight away.
while (!plain.over && plain.timeStops[0] === FOE) {
plain = resolveTurn(plain, { A: yours, B: chooseBotTurn(plain, FOE) });
while (!plain.over && plain.timeStops[0] === LOCAL_BOT) {
plain = resolveTurn(plain, { [LOCAL_BOT]: chooseBotTurn(plain, LOCAL_BOT) });
}
this.state = plain;
this.turnOver(plain);
}
private async send(yours: TurnInput): Promise<void> {
const r = this.remote!;
this.sending = true;
r.error = '';
try {
this.apply(await api.turn(r.roomId, r.token, yours));
} catch (e) {
r.error = e instanceof Error ? e.message : 'The move did not reach the server.';
} finally {
this.sending = false;
}
}
/** Take a fresh view from the server; a new turn clears the drafts. */
apply(view: RoomView): void {
const r = this.remote!;
r.seats = view.seats;
r.size = view.size;
r.waitingOn = view.waitingOn;
const wasStarted = r.started;
r.started = view.state !== null;
if (!view.state) return;
const advanced = !wasStarted || view.state.turn !== this.state.turn || view.state.log.length !== this.state.log.length;
if (advanced) this.turnOver(view.state);
else this.state = view.state;
}
/** Fetch the view now, and keep fetching whenever the server says something changed. */
connect(): () => void {
const r = this.remote;
if (!r) return () => {};
const refresh = () => {
api.view(r.roomId, r.token).then((v) => this.apply(v)).catch((e: Error) => (r.error = e.message));
};
return watchRoom(r.roomId, r.token, refresh, (open) => (r.connected = open));
}
/** Seat the bot in the empty chair. */
async addBot(): Promise<void> {
const r = this.remote;
if (!r) return;
try {
this.apply(await api.addBot(r.roomId, r.token));
} catch (e) {
r.error = e instanceof Error ? e.message : 'The bot could not be seated.';
}
}
/** A resolved turn has arrived, from the engine here or from the server. */
private turnOver(next: GameState): void {
const finished = this.planned;
this.state = next;
for (const h of HANDS) {
const pinned = this.pins[h];
if (pinned && this.planned.some((p) => p.hand === h && p.completion.spell.id === pinned)) this.pins[h] = null;
if (pinned && finished.some((p) => p.hand === h && p.completion.spell.id === pinned)) this.pins[h] = null;
}
this.resetDrafts();
// On a phone the hands sit far below the ledger; bring the result into view.
@@ -642,14 +763,15 @@ export class Duel {
private resetDrafts(): void {
this.sets = blankSets();
this.syncAll();
this.stabTarget = FOE;
this.stabTarget = '';
// Monsters keep attacking what they attacked last, until told otherwise or the target is gone.
const orders: Record<string, TargetId> = {};
for (const m of this.state.monsters) {
if (m.owner !== YOU) continue;
if (m.owner !== this.me) continue;
const last = m.lastTarget;
const valid = last === FOE || this.state.monsters.some((o) => o.id === last && o.owner === FOE);
orders[m.id] = valid && last ? last : FOE;
const standing = last !== undefined && this.others.includes(last) && this.state.wizards[last].alive;
const creature = this.state.monsters.some((o) => o.id === last && o.owner !== this.me);
orders[m.id] = last && (standing || creature) ? last : this.foeId;
}
this.monsterOrders = orders;
this.release = '';
@@ -659,11 +781,49 @@ export class Duel {
}
newGame(): void {
if (this.remote) return;
this.state = createGame(pickNames());
this.pins = { left: null, right: null };
this.resetDrafts();
}
/** Open a duel for another person on the server, seated as the first wizard. */
static async createRoom(name: string, size = 2): Promise<Remote> {
const seated = await api.create(name, size);
rememberSeat(seated.view.roomId, { seat: seated.seat, token: seated.token });
return remoteFrom(seated.view, seated.token);
}
static async joinRoom(roomId: string, name: string): Promise<Remote> {
const seated = await api.join(roomId, name);
rememberSeat(roomId, { seat: seated.seat, token: seated.token });
return remoteFrom(seated.view, seated.token);
}
/** Return to a seat this browser already holds. */
static async resumeRoom(roomId: string, held: HeldSeat): Promise<Remote> {
return remoteFrom(await api.view(roomId, held.token), held.token);
}
}
/** The one duel this browser is playing; it survives moving between pages. */
function remoteFrom(view: RoomView, token: string): Remote {
return {
roomId: view.roomId,
seat: view.me,
token,
size: view.size,
seats: view.seats,
waitingOn: view.waitingOn,
started: view.state !== null,
connected: false,
error: ''
};
}
/** The single-player duel this browser is playing; it survives moving between pages. */
export const duel = new Duel();
/** The name the player last chose, for seating them at a shared duel. */
export function playerName(): string {
return readName();
}
+12
View File
@@ -14,3 +14,15 @@ export function viewFor(state: GameState, viewer: WizardId): GameState {
view.log = view.log.filter((l) => !l.hiddenFrom?.includes(viewer));
return view;
}
/** What a seated player is told by the server. Other seats' moves and tokens never leave it. */
export interface RoomView {
roomId: string;
seq: number;
size: number;
me: WizardId;
seats: { id: WizardId; name: string; bot: boolean }[];
/** Seats that still have to move before the turn resolves. */
waitingOn: WizardId[];
state: GameState | null;
}
+79 -427
View File
@@ -1,14 +1,8 @@
<script lang="ts">
import Chronicle from '$lib/components/Chronicle.svelte';
import HandPicker from '$lib/components/HandPicker.svelte';
import Ledger from '$lib/components/Ledger.svelte';
import MobileBar from '$lib/components/MobileBar.svelte';
import SpellSheet from '$lib/components/SpellSheet.svelte';
import TurnSummary from '$lib/components/TurnSummary.svelte';
import WizardStatus from '$lib/components/WizardStatus.svelte';
import { COMPACT_QUERY, duel, FOE, YOU, NAME_MAX, castKey, tokensText } from '$lib/game/duel.svelte';
import { GESTURE_SHORT, glyph } from '$lib/game/glyphs';
import { MAGIC_GESTURES } from '$lib/game/spells';
import { goto } from '$app/navigation';
import Board from '$lib/components/Board.svelte';
import Hall from '$lib/components/Hall.svelte';
import { COMPACT_QUERY, duel, NAME_MAX } from '$lib/game/duel.svelte';
let confirmingNew = $state(false);
let renaming = $state(false);
@@ -46,9 +40,6 @@
return () => query.removeEventListener('change', apply);
});
const foeMonsters = $derived(duel.state.monsters.filter((m) => m.owner === FOE));
const outcome = $derived(duel.state.over);
const won = $derived(outcome?.winner === YOU);
</script>
<svelte:head>
@@ -78,6 +69,7 @@
<nav class="actions">
<a class="quiet" href="/rules">Rules</a>
<button type="button" class="quiet" onclick={startNew}>New duel</button>
<a class="quiet" href="#hall">Duel a friend</a>
</nav>
</header>
@@ -91,176 +83,9 @@
</div>
{/if}
<main class="board">
<section class="play">
<Ledger {duel} />
<TurnSummary {duel} />
<Board {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>
<button type="button" class="reveal" onclick={() => duel.newGame()}>Duel again</button>
</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.highlight?.hand === t.hand && duel.highlight.from === t.from}
<li>
<button type="button" class="threat soon" class:active aria-pressed={active} onclick={() => duel.toggleHighlight(t)}>
<span class="thand">{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.highlight?.hand === t.hand && duel.highlight.from === t.from && duel.highlight.to === t.to}
<li>
<button type="button" class="threat" class:active aria-pressed={active} onclick={() => duel.toggleHighlight(t)}>
<span class="thand">{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}>
<option value={FOE}>{duel.foe.name}</option>
{#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]}>
<option value={FOE}>{duel.foe.name}</option>
{#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>{duel.foe.name}'s charmed {duel.foe.constraints.charmed?.hand} 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.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.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 />
<WizardStatus wizard={duel.foe} monsters={foeMonsters} />
<SpellSheet {duel} />
<Chronicle log={duel.state.log} />
</aside>
</main>
<MobileBar {duel} />
<Hall name={duel.you.name} />
<footer class="colophon">
<p>After Richard Bartle's <em>Waving Hands</em> (1977), also known as Spellbinder and Spellcaster. <a href="/rules">Read the full rules</a>.</p>
@@ -268,285 +93,112 @@
<style>
.masthead {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 1rem var(--gutter) 0.6rem;
max-width: 1280px;
margin: 0 auto;
}
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 1rem var(--gutter) 0.6rem;
max-width: 1280px;
margin: 0 auto;
}
h1 {
font-size: clamp(2rem, 4vw, 2.7rem);
font-weight: 300;
font-variation-settings: 'opsz' 144;
letter-spacing: -0.01em;
}
font-size: clamp(2rem, 4vw, 2.7rem);
font-weight: 300;
font-variation-settings: 'opsz' 144;
letter-spacing: -0.01em;
}
.standfirst {
max-width: 38em;
color: var(--bone-dim);
margin-top: 0.4rem;
}
max-width: 38em;
color: var(--bone-dim);
margin-top: 0.4rem;
}
.standfirst em {
color: var(--bone);
}
color: var(--bone);
}
.standfirst .link {
margin-left: 0.5em;
font-size: 0.8rem;
}
margin-left: 0.5em;
font-size: 0.8rem;
}
.rename {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 0.75rem;
margin-top: 0.4rem;
}
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 0.75rem;
margin-top: 0.4rem;
}
.rename input {
width: 14em;
}
width: 14em;
}
.actions {
display: flex;
gap: 0.5rem;
flex-shrink: 0;
}
display: flex;
gap: 0.5rem;
flex-shrink: 0;
}
.quiet {
background: none;
border: 1px solid var(--rule-strong);
border-radius: 4px;
padding: 0.4rem 0.9rem;
white-space: nowrap;
color: var(--bone);
text-decoration: none;
line-height: 1.55;
}
background: none;
border: 1px solid var(--rule-strong);
border-radius: 4px;
padding: 0.4rem 0.9rem;
white-space: nowrap;
color: var(--bone);
text-decoration: none;
line-height: 1.55;
}
.quiet:hover {
border-color: var(--bone);
}
border-color: var(--bone);
}
.confirm {
max-width: 1280px;
margin: 0 auto 0.75rem;
padding: 0.75rem var(--gutter);
}
max-width: 1280px;
margin: 0 auto 0.75rem;
padding: 0.75rem var(--gutter);
}
.confirm p {
color: var(--blood);
margin-bottom: 0.5rem;
}
color: var(--blood);
margin-bottom: 0.5rem;
}
.choices {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
}
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;
}
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
}
.reveal.small {
padding: 0.4rem 0.9rem;
font-size: 1rem;
}
.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);
}
padding: 0.4rem 0.9rem;
font-size: 1rem;
}
.colophon {
max-width: 1280px;
margin: 0 auto;
padding: 2rem var(--gutter) 3rem;
color: var(--bone-faint);
font-size: 0.85rem;
}
max-width: 1280px;
margin: 0 auto;
padding: 2rem var(--gutter) 3rem;
color: var(--bone-faint);
font-size: 0.85rem;
}
.colophon a {
color: var(--frost);
}
color: var(--frost);
}
/* Must match COMPACT_QUERY in duel.svelte.ts. */
@media (max-width: 860px) {
.board {
grid-template-columns: minmax(0, 1fr);
}
.colophon {
padding-bottom: 6rem;
}
/* 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;
}
padding-bottom: 6rem;
}
.masthead {
flex-direction: column;
}
flex-direction: column;
}
}
</style>
+285
View File
@@ -0,0 +1,285 @@
<script lang="ts">
import { page } from '$app/state';
import Board from '$lib/components/Board.svelte';
import { seatFor } from '$lib/game/client';
import { COMPACT_QUERY, Duel, NAME_MAX, playerName } from '$lib/game/duel.svelte';
const roomId = $derived((page.params.code ?? '').toUpperCase());
let room = $state<Duel | null>(null);
let name = $state('');
let joining = $state(false);
let busy = $state(false);
let error = $state('');
let copied = $state(false);
const link = $derived(typeof location === 'undefined' ? '' : `${location.origin}/join/${roomId}`);
const lobby = $derived(room !== null && !room.remote?.started);
// Return to a held seat, or offer one.
$effect(() => {
const id = roomId;
room = null;
error = '';
const held = seatFor(id);
if (!held) {
name = playerName();
joining = true;
return;
}
joining = false;
Duel.resumeRoom(id, held)
.then((remote) => (room = new Duel(remote)))
.catch((e: Error) => (error = e.message));
});
// Keep the view fresh for as long as the room is on screen.
$effect(() => {
const r = room;
if (!r) return;
const stop = r.connect();
return stop;
});
// The store's idea of a narrow screen must agree with the stylesheets' breakpoint.
$effect(() => {
const r = room;
if (!r) return;
const query = window.matchMedia(COMPACT_QUERY);
const apply = () => (r.compact = query.matches);
apply();
query.addEventListener('change', apply);
return () => query.removeEventListener('change', apply);
});
async function takeSeat(e: SubmitEvent) {
e.preventDefault();
busy = true;
error = '';
try {
room = new Duel(await Duel.joinRoom(roomId, name));
joining = false;
} catch (err) {
error = err instanceof Error ? err.message : 'The seat could not be taken.';
} finally {
busy = false;
}
}
async function copyLink() {
try {
await navigator.clipboard.writeText(link);
copied = true;
setTimeout(() => (copied = false), 1600);
} catch {
// The link is on screen to copy by hand.
}
}
</script>
<svelte:head>
<title>Waving Hands: a duel</title>
</svelte:head>
<header class="masthead">
<div>
<h1>Waving Hands</h1>
{#if room?.remote?.started}
<p class="standfirst">
You are <em>{room.you.name}</em>, duelling
{#each room.others as id, i (id)}{#if i > 0}{i === room.others.length - 1 ? ' and ' : ', '}{/if}<em>{room.state.wizards[id].name}</em>{/each}.
{#if !room.remote.connected}<span class="muted">Reconnecting to the duel.</span>{/if}
</p>
{:else}
<p class="standfirst">A duel between people, played by turns whenever each of you has a moment.</p>
{/if}
</div>
<nav class="actions">
{#if room}
<button type="button" class="quiet room" title="Copy the invite link" onclick={copyLink}>
<span class="muted">invite friends · room</span> <b>{roomId}</b>{copied ? ' · link copied' : ''}
</button>
{/if}
<a class="quiet" href="/rules">Rules</a>
<a class="quiet" href="/">The hall</a>
</nav>
</header>
{#if error}
<p class="notice">{error}</p>
{/if}
{#if joining}
<section class="lobby">
<p class="eyebrow">room {roomId}</p>
<h2>Take a seat</h2>
<p>Another wizard has opened this duel and is waiting for an opponent.</p>
<form class="rename" onsubmit={takeSeat}>
<label class="field">
<span>Your name</span>
<input type="text" bind:value={name} maxlength={NAME_MAX} autocomplete="nickname" required />
</label>
<button type="submit" class="reveal small" disabled={busy}>Sit down</button>
</form>
</section>
{:else if room && lobby}
<section class="lobby">
<p class="eyebrow">room {roomId}</p>
<h2>Waiting for {room.remote!.size - room.remote!.seats.length === 1 ? 'one more wizard' : `${room.remote!.size - room.remote!.seats.length} more wizards`}</h2>
<p>Send this link, or the code. Whoever opens it takes the empty seat, and the duel begins.</p>
<p class="link-row"><code>{link}</code><button type="button" class="quiet" onclick={copyLink}>{copied ? 'Copied' : 'Copy link'}</button></p>
<p class="muted">so far: {room.remote!.seats.map((s) => s.name).join(', ')}</p>
<p><button type="button" class="quiet" onclick={() => room?.addBot()}>Seat the bot instead</button></p>
</section>
{:else if room}
<Board duel={room} />
{:else if !error}
<p class="notice muted">Finding your seat.</p>
{/if}
<footer class="colophon">
<p>After Richard Bartle's <em>Waving Hands</em> (1977), also known as Spellbinder and Spellcaster. <a href="/rules">Read the full rules</a>.</p>
</footer>
<style>
.masthead {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 1rem var(--gutter) 0.6rem;
max-width: 1280px;
margin: 0 auto;
}
h1 {
font-size: clamp(2rem, 4vw, 2.7rem);
font-weight: 300;
font-variation-settings: 'opsz' 144;
letter-spacing: -0.01em;
}
.standfirst {
max-width: 38em;
color: var(--bone-dim);
margin-top: 0.4rem;
}
.standfirst em {
color: var(--bone);
}
.actions {
display: flex;
gap: 0.5rem;
flex-shrink: 0;
}
.quiet {
background: none;
border: 1px solid var(--rule-strong);
border-radius: 4px;
padding: 0.4rem 0.9rem;
white-space: nowrap;
color: var(--bone);
text-decoration: none;
line-height: 1.55;
}
.quiet:hover {
border-color: var(--bone);
}
.quiet.room b {
letter-spacing: 0.12em;
font-weight: 600;
}
.eyebrow {
font-size: 0.85rem;
letter-spacing: 0.12em;
color: var(--bone-faint);
margin-bottom: 0.2rem;
}
.notice {
max-width: 1280px;
margin: 0 auto;
padding: 0 var(--gutter) 0.5rem;
color: var(--blood);
}
.notice.muted {
color: var(--bone-dim);
}
.lobby {
max-width: 1280px;
margin: 0 auto;
padding: 1rem var(--gutter) 2rem;
}
.lobby h2 {
font-size: 1.4rem;
margin-bottom: 0.4rem;
}
.lobby p + p {
margin-top: 0.6rem;
}
.rename {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 0.75rem;
margin-top: 0.8rem;
}
.rename input {
width: 14em;
}
.reveal.small {
padding: 0.4rem 0.9rem;
font-size: 1rem;
}
.link-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 0.75rem;
}
code {
font-family: var(--font);
background: var(--slate);
padding: 0.3rem 0.6rem;
border-radius: 4px;
word-break: break-all;
}
.colophon {
max-width: 1280px;
margin: 0 auto;
padding: 2rem var(--gutter) 3rem;
color: var(--bone-faint);
font-size: 0.85rem;
}
.colophon a {
color: var(--frost);
}
/* Must match COMPACT_QUERY in duel.svelte.ts. */
@media (max-width: 860px) {
.masthead {
flex-direction: column;
}
.colophon {
padding-bottom: 6rem;
}
}
</style>