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
+17 -18
View File
@@ -8,7 +8,7 @@ import { chooseBotTurn } from '../../src/lib/game/bot';
import { resolveTurn } from '../../src/lib/game/resolve'; import { resolveTurn } from '../../src/lib/game/resolve';
import { GESTURES, type Gesture } from '../../src/lib/game/spells'; import { GESTURES, type Gesture } from '../../src/lib/game/spells';
import { createGame, inDuel, type GameState, type TurnInput, type WizardId } from '../../src/lib/game/state'; import { createGame, inDuel, type GameState, type TurnInput, type WizardId } from '../../src/lib/game/state';
import { viewFor } from '../../src/lib/game/view'; import { viewFor, type RoomView } from '../../src/lib/game/view';
import type { LedgerLine, Store } from './store'; import type { LedgerLine, Store } from './store';
const SEAT_IDS: WizardId[] = ['A', 'B', 'C', 'D']; const SEAT_IDS: WizardId[] = ['A', 'B', 'C', 'D'];
@@ -35,18 +35,6 @@ export interface Room {
updatedAt: number; updatedAt: number;
} }
/** What a seated player is told. Other seats' pending inputs and tokens never leave the server. */
export interface View {
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;
}
export class RoomError extends Error { export class RoomError extends Error {
constructor( constructor(
message: string, message: string,
@@ -60,6 +48,17 @@ function newId(bytes: number): string {
return randomBytes(bytes).toString('base64url'); return randomBytes(bytes).toString('base64url');
} }
/** Room codes: four letters or digits, none that read alike, easy to say aloud. */
const CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
function newCode(): string {
const bytes = randomBytes(4);
return [...bytes].map((b) => CODE_ALPHABET[b % CODE_ALPHABET.length]).join('');
}
export function normalizeCode(raw: string): string {
return raw.trim().toUpperCase();
}
export class Rooms { export class Rooms {
private rooms = new Map<string, Room>(); private rooms = new Map<string, Room>();
private listeners = new Map<string, Set<(room: Room) => void>>(); private listeners = new Map<string, Set<(room: Room) => void>>();
@@ -72,8 +71,8 @@ export class Rooms {
} }
get(id: string): Room { get(id: string): Room {
const room = this.rooms.get(id); const room = this.rooms.get(id) ?? this.rooms.get(normalizeCode(id));
if (!room) throw new RoomError('No such duel.', 404); if (!room) throw new RoomError('No duel answers to that code.', 404);
return room; return room;
} }
@@ -86,8 +85,8 @@ export class Rooms {
create(name: string, size: number): { room: Room; seat: Seat } { create(name: string, size: number): { room: Room; seat: Seat } {
if (!Number.isInteger(size) || size < 2 || size > MAX_SEATS) throw new RoomError(`A duel seats 2 to ${MAX_SEATS} wizards.`); if (!Number.isInteger(size) || size < 2 || size > MAX_SEATS) throw new RoomError(`A duel seats 2 to ${MAX_SEATS} wizards.`);
let id = newId(6); let id = newCode();
while (this.rooms.has(id)) id = newId(6); while (this.rooms.has(id)) id = newCode();
const room: Room = { id, size, createdAt: Date.now(), seats: [], state: null, pending: {}, seq: 0, updatedAt: Date.now() }; const room: Room = { id, size, createdAt: Date.now(), seats: [], state: null, pending: {}, seq: 0, updatedAt: Date.now() };
this.rooms.set(id, room); this.rooms.set(id, room);
this.commit(room, { t: 'room', id, seats: size, createdAt: room.createdAt }); this.commit(room, { t: 'room', id, seats: size, createdAt: room.createdAt });
@@ -144,7 +143,7 @@ export class Rooms {
} }
} }
view(room: Room, seatId: WizardId): View { view(room: Room, seatId: WizardId): RoomView {
return { return {
roomId: room.id, roomId: room.id,
seq: room.seq, seq: room.seq,
+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"> <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 { glyph } from '$lib/game/glyphs';
import { SPELL_BY_ID, type Hand } from '$lib/game/spells'; import { SPELL_BY_ID, type Hand } from '$lib/game/spells';
import type { HistoryEntry, WizardId } from '$lib/game/state'; import type { HistoryEntry, WizardId } from '$lib/game/state';
@@ -21,13 +21,16 @@
cells: Record<WizardId, Placed[]>; 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[] { 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 raw = duel.state.wizards[who].history;
const out: Placed[] = []; const out: Placed[] = [];
history.forEach((entry, index) => { history.forEach((entry, index) => {
if (entry.turn === turn && phases.includes(entry.phase)) { 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; return out;
@@ -36,10 +39,14 @@
const rows = $derived.by(() => { const rows = $derived.by(() => {
const out: Row[] = []; const out: Row[] = [];
for (let t = 0; t < duel.state.turn; t++) { 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']) } }); const cells: Record<WizardId, Placed[]> = {};
for (const who of [YOU, FOE] as WizardId[]) { 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'])) { 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 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[] { function notesFor(who: WizardId, index: number, hand: Hand): string[] {
return duel.state.wizards[who].casts return duel.state.wizards[who].casts
@@ -55,6 +62,11 @@
.map((c) => SPELL_BY_ID[c.spellId].name); .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(() => { $effect(() => {
void turns; void turns;
if (scroller) scroller.scrollTop = scroller.scrollHeight; if (scroller) scroller.scrollTop = scroller.scrollHeight;
@@ -66,29 +78,29 @@
<thead> <thead>
<tr> <tr>
<th class="turn" scope="col"><span class="visually-hidden">Turn</span></th> <th class="turn" scope="col"><span class="visually-hidden">Turn</span></th>
<th class="you" colspan="2" scope="colgroup">{duel.you.name} (you)</th> {#each columns as who (who)}
<th class="foe" colspan="2" scope="colgroup">{duel.foe.name}</th> <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>
<tr class="hands"> <tr class="hands">
<th></th> <th></th>
<th scope="col">left</th> {#each columns as who (who)}
<th scope="col">right</th> <th scope="col">left</th>
<th scope="col">left</th> <th scope="col">right</th>
<th scope="col">right</th> {/each}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each rows as row (row.key)} {#each rows as row (row.key)}
<tr class:stopped={!!row.note}> <tr class:stopped={!!row.note}>
<td class="turn">{row.label}{#if row.note}<span class="rownote">{row.note}</span>{/if}</td> <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)} {#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)} {#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 notes = notesFor(who, p.index, hand)}
{@const other = hand === 'left' ? p.entry.right : p.entry.left} {@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> <span class="letter">{p.hidden ? '?' : glyph(p.entry[hand], other)}</span>
{#if notes.length}<span class="note">{notes.join(', ')}</span>{/if} {#if notes.length}<span class="note">{notes.join(', ')}</span>{/if}
</span> </span>
@@ -113,8 +125,10 @@
{/each} {/each}
</td> </td>
{/each} {/each}
<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>
<td class="cell foe">{#if !duel.timeStopped}<span class="letter placeholder">?</span>{/if}</td>
{/each}
</tr> </tr>
{/if} {/if}
</tbody> </tbody>
@@ -193,10 +207,14 @@
background: rgba(140, 200, 224, 0.06); 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); border-right: 1px solid var(--rule-strong);
} }
td.cell:last-child {
border-right: 0;
}
.entry { .entry {
display: block; display: block;
} }
+3 -3
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <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(); let { duel }: { duel: Duel } = $props();
@@ -14,10 +14,10 @@
<section class="result" id="turn-result" aria-label="What the last reveal did"> <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> <h2>{summary.phase === 'timestop' ? 'In the stopped moment' : `Turn ${summary.turn + 1}`}</h2>
<dl> <dl>
{#each [YOU, FOE] as const as who (who)} {#each duel.state.seats as who (who)}
{@const w = summary.wizards[who]} {@const w = summary.wizards[who]}
<div> <div>
<dt>{who === YOU ? 'You' : duel.foe.name}</dt> <dt>{who === duel.me ? 'You' : duel.state.wizards[who].name}</dt>
<dd> <dd>
{#if w.effects.length === 0} {#if w.effects.length === 0}
<span class="muted">untouched</span> <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 { tick } from 'svelte';
import { chooseBotTurn } from './bot'; import { chooseBotTurn } from './bot';
import { api, rememberSeat, watchRoom, type HeldSeat } from './client';
import { import {
cellsOverlap, cellsOverlap,
completableIn, completableIn,
@@ -31,6 +34,7 @@ import {
import { import {
NOWHERE, NOWHERE,
createGame, createGame,
enemyOf,
visibleHistory, visibleHistory,
type CastChoice, type CastChoice,
type GameState, type GameState,
@@ -39,9 +43,11 @@ import {
type TurnInput, type TurnInput,
type WizardId type WizardId
} from './state'; } from './state';
import type { RoomView } from './view';
export const YOU: WizardId = 'A'; /** The seats of a duel played in this browser against the bot. */
export const FOE: WizardId = 'B'; 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. */ /** 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)'; export const COMPACT_QUERY = '(max-width: 860px)';
@@ -113,8 +119,9 @@ export interface Plan {
pinned: boolean; 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 { export interface Threat {
who: WizardId;
hand: Hand; hand: Hand;
spell: Spell; spell: Spell;
done: Token[]; done: Token[];
@@ -126,6 +133,7 @@ export interface Threat {
} }
export interface Highlight { export interface Highlight {
who: WizardId;
hand: Hand; hand: Hand;
from: number; from: number;
to: number; to: number;
@@ -160,6 +168,7 @@ function loadSaved(): SavedDuel | null {
const saved = JSON.parse(raw) as SavedDuel; const saved = JSON.parse(raw) as SavedDuel;
if (saved.v !== SAVE_VERSION || !saved.state?.wizards?.A || !saved.state?.wizards?.B) return null; 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. // 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 ??= []; saved.state.lessonsShown ??= [];
if (saved.state.lastTurn) { if (saved.state.lastTurn) {
saved.state.lastTurn.events ??= []; 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 { export class Duel {
state = $state<GameState>(createGame(pickNames())); 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()); sets = $state<Record<SetName, Record<Hand, HandDraft>>>(blankSets());
stabTarget = $state<TargetId>(FOE); stabTarget = $state<TargetId | ''>('');
monsterOrders = $state<Record<string, TargetId>>({}); monsterOrders = $state<Record<string, TargetId>>({});
/** Narrow screens get shorter lists and a sticky bar; the page sets this from a media query. */ /** Narrow screens get shorter lists and a sticky bar; the page sets this from a media query. */
compact = $state(false); compact = $state(false);
@@ -209,16 +232,22 @@ export class Duel {
sheetOpen = $state(false); sheetOpen = $state(false);
/** Opponent gestures to light up in the ledger. */ /** Opponent gestures to light up in the ledger. */
highlight = $state<Highlight | null>(null); 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(); 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; this.state = initial;
} else if (saved) { } else if (saved) {
this.state = saved.state; this.state = saved.state;
this.pins = saved.pins ?? { left: null, right: null }; this.pins = saved.pins ?? { left: null, right: null };
this.sets = saved.sets ?? blankSets(); this.sets = saved.sets ?? blankSets();
this.stabTarget = saved.stabTarget ?? FOE; this.stabTarget = saved.stabTarget ?? '';
this.monsterOrders = saved.monsterOrders ?? {}; this.monsterOrders = saved.monsterOrders ?? {};
this.release = saved.release ?? ''; this.release = saved.release ?? '';
} }
@@ -243,15 +272,17 @@ export class Duel {
} catch { } catch {
// The name still applies to this duel. // The name still applies to this duel.
} }
if (this.remote) return;
const next = name || pickNames('').A; const next = name || pickNames('').A;
if (next.toLowerCase() === this.foe.name.toLowerCase()) { 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. */ /** Write the duel and the half-written turn to local storage. Reads everything it saves, so an effect can track it. */
persist(): void { persist(): void {
if (this.remote) return;
const bundle: SavedDuel = { const bundle: SavedDuel = {
v: SAVE_VERSION, v: SAVE_VERSION,
state: $state.snapshot(this.state), state: $state.snapshot(this.state),
@@ -271,11 +302,21 @@ export class Duel {
/** A duel worth asking about before it is thrown away. */ /** A duel worth asking about before it is thrown away. */
inProgress = $derived(this.state.turn > 0 && !this.state.over); inProgress = $derived(this.state.turn > 0 && !this.state.over);
you = $derived(this.state.wizards[YOU]); you = $derived(this.state.wizards[this.me]);
foe = $derived(this.state.wizards[FOE]); /** 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); turnNumber = $derived(this.state.turn + 1);
/** Your extra turn under time stop: nobody else moves and last turn's enchantments do not bind. */ /** 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); hasted = $derived(this.you.hasteTurns > 0 && !this.timeStopped);
activeSets = $derived<SetName[]>(this.hasted ? ['main', 'haste'] : ['main']); activeSets = $derived<SetName[]>(this.hasted ? ['main', 'haste'] : ['main']);
@@ -295,8 +336,9 @@ export class Duel {
return set === 'haste' ? this.forcedSecond[hand] : this.forced[hand]; return set === 'haste' ? this.forcedSecond[hand] : this.forced[hand];
} }
charmedHand = $derived<Hand | undefined>(this.timeStopped ? undefined : this.you.constraints.charmed?.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. */ /** The opponent whose hand is yours to command this turn, if any. */
youCharmedFoe = $derived(this.foe.constraints.charmed?.by === YOU && !this.timeStopped); 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. */ /** The gesture a hand will make: forced by an enchantment, or chosen, or nothing yet. */
chosenGesture(set: SetName, hand: Hand): Gesture | null { chosenGesture(set: SetName, hand: Hand): Gesture | null {
@@ -392,20 +434,28 @@ export class Duel {
} }
summary = $derived(this.state.lastTurn); summary = $derived(this.state.lastTurn);
foeVisible = $derived(visibleHistory(this.state, YOU, FOE)); /** Each opponent's gestures as you are allowed to see them. */
/** Hostile spells the opponent could finish this turn or next, with the evidence. */ 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(() => { threats = $derived.by(() => {
const out: Threat[] = []; const out: Threat[] = [];
for (const hand of HANDS) { for (const who of this.others) {
const seq = handSequence(this.foeVisible, hand); const w = this.state.wizards[who];
for (const turnsAway of [1, 2]) { if (!w.alive || w.surrendered) continue;
for (const c of completableIn(seq, this.foe.sequenceStart, turnsAway)) { const hist = this.visible(who);
if (!HOSTILE_SPELLS.includes(c.spell.id)) continue; for (const hand of HANDS) {
if (c.spell.id === 'lightning_bolt_quick' && this.foe.usedQuickLightning) continue; const seq = handSequence(hist, hand);
const tokens = c.spell.tokens[c.seqIndex]; for (const turnsAway of [1, 2]) {
const done = tokens.slice(0, tokens.length - turnsAway); for (const c of completableIn(seq, w.sequenceStart, turnsAway)) {
if (out.some((t) => t.hand === hand && t.spell.id === c.spell.id)) continue; if (!HOSTILE_SPELLS.includes(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 }); 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)); threatsNext = $derived(this.threats.filter((t) => !t.soon).slice(0, 6));
toggleHighlight(t: Threat): void { toggleHighlight(t: Threat): void {
const same = this.highlight && this.highlight.hand === t.hand && this.highlight.from === t.from && this.highlight.to === t.to; const h = this.highlight;
this.highlight = same ? null : { hand: t.hand, from: t.from, to: t.to }; 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. */ /** 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')); 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) === '>'))); 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( ready = $derived(
!this.state.over && !this.state.over &&
!this.moved &&
!this.sending &&
this.activeSets.every((set) => this.activeSets.every((set) =>
HANDS.every((h) => this.forced[h] || this.charmedHand === h || this.sets[set][h].gesture !== null) 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. */ /** A delayed effect is waiting for a spell to bank, counting one cast this turn. */
bankingActive = $derived( bankingActive = $derived(
!this.you.banked && !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') : []); bankCandidates = $derived(this.bankingActive ? this.planned.filter((p) => p.completion.spell.id !== 'delayed_effect') : []);
willBank = $derived( willBank = $derived(
this.bankCandidates.find((p) => castKey(p.set, p.hand) === this.bankPick) ?? this.bankCandidates[0] this.bankCandidates.find((p) => castKey(p.set, p.hand) === this.bankPick) ?? this.bankCandidates[0]
); );
permanencyActive = $derived( 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( permanentCandidates = $derived(
this.permanencyActive this.permanencyActive
@@ -539,17 +598,15 @@ export class Duel {
/** Where a spell goes unless the player says otherwise. */ /** Where a spell goes unless the player says otherwise. */
defaultTarget(spell: Spell | undefined): TargetId | '' { 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. */ /** Every possible subject, the likeliest first, creatures marked by whose they are. */
targetsFor(spell: Spell | undefined): { id: TargetId; label: string }[] { targetsFor(spell: Spell | undefined): { id: TargetId; label: string }[] {
const list: { id: TargetId; label: string }[] = [ const list: { id: TargetId; label: string }[] = [{ id: this.me, label: 'yourself' }];
{ id: YOU, label: 'yourself' }, for (const id of this.others) list.push({ id, label: this.state.wizards[id].name });
{ id: FOE, label: this.foe.name }
];
for (const m of this.state.monsters) { 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); const likely = this.defaultTarget(spell);
list.sort((a, b) => Number(b.id === likely) - Number(a.id === likely)); 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 === 'summon_elemental') cast.elemental = p.draft.elemental;
if (spell.id === 'paralysis' || spell.id === 'charm_person') cast.chosenHand = p.draft.chosenHand; 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.willBank) cast.bank = true;
if (p === this.willExtend) cast.permanent = true; if (p === this.willExtend) cast.permanent = true;
return cast; return cast;
} }
reveal(): void { /** The move as the engine wants it. */
if (!this.ready) return; private input(): TurnInput {
const orders: Record<string, TargetId> = {}; 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 = { const yours: TurnInput = {
left: this.sets.main.left.gesture ?? '-', left: this.sets.main.left.gesture ?? '-',
right: this.sets.main.right.gesture ?? '-', right: this.sets.main.right.gesture ?? '-',
casts: this.planned.filter((p) => p.set === 'main').map((p) => this.castFor(p)), casts: this.planned.filter((p) => p.set === 'main').map((p) => this.castFor(p)),
stabTarget: this.stabTarget, stabTarget: this.stabTarget || this.foeId,
monsterOrders: orders monsterOrders: orders
}; };
if (this.hasted) { if (this.hasted) {
@@ -610,23 +667,87 @@ export class Duel {
left: this.sets.haste.left.gesture ?? '-', left: this.sets.haste.left.gesture ?? '-',
right: this.sets.haste.right.gesture ?? '-', right: this.sets.haste.right.gesture ?? '-',
casts: this.planned.filter((p) => p.set === 'haste').map((p) => this.castFor(p)), 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; 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. // The engine clones plain data; hand it a snapshot rather than the reactive proxy.
let plain = $state.snapshot(this.state); let plain = $state.snapshot(this.state);
const theirs = chooseBotTurn(plain, FOE); const theirs = chooseBotTurn(plain, LOCAL_BOT);
plain = resolveTurn(plain, { A: yours, B: theirs }); plain = resolveTurn(plain, { [LOCAL_ME]: yours, [LOCAL_BOT]: theirs });
// The bot takes any stopped moments it is owed straight away. // The bot takes any stopped moments it is owed straight away.
while (!plain.over && plain.timeStops[0] === FOE) { while (!plain.over && plain.timeStops[0] === LOCAL_BOT) {
plain = resolveTurn(plain, { A: yours, B: chooseBotTurn(plain, FOE) }); 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) { for (const h of HANDS) {
const pinned = this.pins[h]; 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(); this.resetDrafts();
// On a phone the hands sit far below the ledger; bring the result into view. // 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 { private resetDrafts(): void {
this.sets = blankSets(); this.sets = blankSets();
this.syncAll(); this.syncAll();
this.stabTarget = FOE; this.stabTarget = '';
// Monsters keep attacking what they attacked last, until told otherwise or the target is gone. // Monsters keep attacking what they attacked last, until told otherwise or the target is gone.
const orders: Record<string, TargetId> = {}; const orders: Record<string, TargetId> = {};
for (const m of this.state.monsters) { for (const m of this.state.monsters) {
if (m.owner !== YOU) continue; if (m.owner !== this.me) continue;
const last = m.lastTarget; const last = m.lastTarget;
const valid = last === FOE || this.state.monsters.some((o) => o.id === last && o.owner === FOE); const standing = last !== undefined && this.others.includes(last) && this.state.wizards[last].alive;
orders[m.id] = valid && last ? last : FOE; 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.monsterOrders = orders;
this.release = ''; this.release = '';
@@ -659,11 +781,49 @@ export class Duel {
} }
newGame(): void { newGame(): void {
if (this.remote) return;
this.state = createGame(pickNames()); this.state = createGame(pickNames());
this.pins = { left: null, right: null }; this.pins = { left: null, right: null };
this.resetDrafts(); 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(); 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)); view.log = view.log.filter((l) => !l.hiddenFrom?.includes(viewer));
return view; 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;
}
+78 -426
View File
@@ -1,14 +1,8 @@
<script lang="ts"> <script lang="ts">
import Chronicle from '$lib/components/Chronicle.svelte'; import { goto } from '$app/navigation';
import HandPicker from '$lib/components/HandPicker.svelte'; import Board from '$lib/components/Board.svelte';
import Ledger from '$lib/components/Ledger.svelte'; import Hall from '$lib/components/Hall.svelte';
import MobileBar from '$lib/components/MobileBar.svelte'; import { COMPACT_QUERY, duel, NAME_MAX } from '$lib/game/duel.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';
let confirmingNew = $state(false); let confirmingNew = $state(false);
let renaming = $state(false); let renaming = $state(false);
@@ -46,9 +40,6 @@
return () => query.removeEventListener('change', apply); 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> </script>
<svelte:head> <svelte:head>
@@ -78,6 +69,7 @@
<nav class="actions"> <nav class="actions">
<a class="quiet" href="/rules">Rules</a> <a class="quiet" href="/rules">Rules</a>
<button type="button" class="quiet" onclick={startNew}>New duel</button> <button type="button" class="quiet" onclick={startNew}>New duel</button>
<a class="quiet" href="#hall">Duel a friend</a>
</nav> </nav>
</header> </header>
@@ -91,176 +83,9 @@
</div> </div>
{/if} {/if}
<main class="board"> <Board {duel} />
<section class="play">
<Ledger {duel} />
<TurnSummary {duel} />
{#if outcome} <Hall name={duel.you.name} />
<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} />
<footer class="colophon"> <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> <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> <style>
.masthead { .masthead {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
gap: 1rem; gap: 1rem;
padding: 1rem var(--gutter) 0.6rem; padding: 1rem var(--gutter) 0.6rem;
max-width: 1280px; max-width: 1280px;
margin: 0 auto; margin: 0 auto;
} }
h1 { h1 {
font-size: clamp(2rem, 4vw, 2.7rem); font-size: clamp(2rem, 4vw, 2.7rem);
font-weight: 300; font-weight: 300;
font-variation-settings: 'opsz' 144; font-variation-settings: 'opsz' 144;
letter-spacing: -0.01em; letter-spacing: -0.01em;
} }
.standfirst { .standfirst {
max-width: 38em; max-width: 38em;
color: var(--bone-dim); color: var(--bone-dim);
margin-top: 0.4rem; margin-top: 0.4rem;
} }
.standfirst em { .standfirst em {
color: var(--bone); color: var(--bone);
} }
.standfirst .link { .standfirst .link {
margin-left: 0.5em; margin-left: 0.5em;
font-size: 0.8rem; font-size: 0.8rem;
} }
.rename { .rename {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
gap: 0.5rem 0.75rem; gap: 0.5rem 0.75rem;
margin-top: 0.4rem; margin-top: 0.4rem;
} }
.rename input { .rename input {
width: 14em; width: 14em;
} }
.actions { .actions {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
flex-shrink: 0; flex-shrink: 0;
} }
.quiet { .quiet {
background: none; background: none;
border: 1px solid var(--rule-strong); border: 1px solid var(--rule-strong);
border-radius: 4px; border-radius: 4px;
padding: 0.4rem 0.9rem; padding: 0.4rem 0.9rem;
white-space: nowrap; white-space: nowrap;
color: var(--bone); color: var(--bone);
text-decoration: none; text-decoration: none;
line-height: 1.55; line-height: 1.55;
} }
.quiet:hover { .quiet:hover {
border-color: var(--bone); border-color: var(--bone);
} }
.confirm { .confirm {
max-width: 1280px; max-width: 1280px;
margin: 0 auto 0.75rem; margin: 0 auto 0.75rem;
padding: 0.75rem var(--gutter); padding: 0.75rem var(--gutter);
} }
.confirm p { .confirm p {
color: var(--blood); color: var(--blood);
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.choices { .choices {
display: flex; display: flex;
gap: 0.6rem; gap: 0.6rem;
flex-wrap: wrap; 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;
}
.reveal.small { .reveal.small {
padding: 0.4rem 0.9rem; padding: 0.4rem 0.9rem;
font-size: 1rem; 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);
}
.colophon { .colophon {
max-width: 1280px; max-width: 1280px;
margin: 0 auto; margin: 0 auto;
padding: 2rem var(--gutter) 3rem; padding: 2rem var(--gutter) 3rem;
color: var(--bone-faint); color: var(--bone-faint);
font-size: 0.85rem; font-size: 0.85rem;
} }
.colophon a { .colophon a {
color: var(--frost); color: var(--frost);
} }
/* Must match COMPACT_QUERY in duel.svelte.ts. */ /* Must match COMPACT_QUERY in duel.svelte.ts. */
@media (max-width: 860px) { @media (max-width: 860px) {
.board {
grid-template-columns: minmax(0, 1fr);
}
.colophon { .colophon {
padding-bottom: 6rem; 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;
}
.masthead { .masthead {
flex-direction: column; flex-direction: column;
} }
} }
</style> </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>