The help becomes a reference desk: searchable, linkable, and it remembers its place

The rules tab is one body — the condensed rules, the rulebook, the
expansion, and the FAQ — with a contents list, a search that labels
each hit by source, and a link on every section. The rulebook's
emphasis renders, and the (6E: …) notes are set apart from the original
words. The card library gains filters for set, type, and target, ranks
an exact name first, counts its results, and says so when there are
none; a card opens as an entry: the card itself beside reading-size
text with the printed wording, deck counts, the official FAQ, this
table's rulings, and links to the cards it names. The house rulings are
organized by card, in the present tense, with the revision history kept
off the page. Every page and card has a link (/?help=rules/…, /?card=…)
that opens the desk to it, and the desk reopens on the tab, search, and
scroll position it was closed at.

About leads with the worn box and the friends, shows the workshops as a
small gallery with a direct link to the demo reel, and says plainly
what an online ledger keeps and what a passed-around device keeps to
itself. The tally leads with finished games, treasure and combat wins,
and hours played, and explains that wizards are counted by name and
table time by the clock between moves.

Escape closes only the foremost layer and returns focus to the card
that opened it; the tabs wrap on a phone instead of clipping.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
Eric Wagoner
2026-09-16 22:13:44 -04:00
co-authored by Claude Fable 5.1
parent 4c2a253723
commit 9dee01d393
4 changed files with 816 additions and 225 deletions
+37 -3
View File
@@ -9,6 +9,7 @@
import Faq from "./Faq.svelte";
import Card from "./Card.svelte";
import Help from "./Help.svelte";
import type { HelpTab } from "./help-state";
import Replay from "./Replay.svelte";
import FxGallery from "./FxGallery.svelte";
import TokenGallery from "./TokenGallery.svelte";
@@ -257,7 +258,39 @@
let faqCardId = $state<string | null>(null);
/** A discard-pile card enlarged above the pile. */
let discardPeek = $state<CardInstance | null>(null);
let helpTab = $state<"play" | "rules" | "cards" | "rulings" | "about" | "tally">("play");
/** The help's opening tab; null reopens it where the reader left off. */
let helpTab = $state<HelpTab | null>(null);
let helpAnchor = $state<string | null>(null);
let helpCard = $state<string | null>(null);
// A link into the reference — /?help=rules/rulebook-line-of-sight or
// /?card=force-field — opens the desk to that page.
{
const q = new URLSearchParams(location.search);
const card = q.get("card");
const help = q.get("help");
const tabs: HelpTab[] = ["play", "rules", "cards", "rulings", "about", "tally"];
if (card) { helpCard = card; showHelp = true; }
else if (help) {
const [t, ...rest] = help.split("/");
if (tabs.includes(t as HelpTab)) { helpTab = t as HelpTab; helpAnchor = rest.join("/") || null; showHelp = true; }
}
}
/** The address bar follows the reader through the help, so any page is a link. */
function helpNavigate(tab: HelpTab, anchor: string | null, card: string | null) {
const u = new URL(location.href);
u.searchParams.delete("help"); u.searchParams.delete("card");
if (card) u.searchParams.set("card", card);
else u.searchParams.set("help", anchor ? `${tab}/${anchor}` : tab);
// The slash stays a slash: /?help=rules/the-cards reads as a path should.
u.search = u.searchParams.toString().replace(/%2F/g, "/");
history.replaceState(null, "", u);
}
function helpClose() {
showHelp = false; helpAnchor = null; helpCard = null; helpTab = null;
const u = new URL(location.href);
u.searchParams.delete("help"); u.searchParams.delete("card");
history.replaceState(null, "", u);
}
let hotseatCount = $state(2);
let setupName = $state("");
let setupColor = $state(0);
@@ -1695,7 +1728,7 @@
<button class="mast-leave" title="something behaved unexpectedly? tell the wizards"
onclick={() => (feedbackOpen = true)}>🐞 report</button>
{/if}
<button class="mast-leave" class:mast-help-solo={!net.roomId} onclick={() => { helpTab = "play"; showHelp = true; }}>
<button class="mast-leave" class:mast-help-solo={!net.roomId} onclick={() => { helpTab = null; showHelp = true; }}>
help &amp; rules
</button>
<span class="mast-status" class:offline={net.status !== "connected"}>
@@ -1969,7 +2002,8 @@
{/if}
{#if showHelp}
<Help initialTab={helpTab} stats={net.stats} onstats={() => net.requestStats()} onclose={() => (showHelp = false)} />
<Help initialTab={helpTab} initialAnchor={helpAnchor} initialCard={helpCard} stats={net.stats}
onstats={() => net.requestStats()} onnavigate={helpNavigate} onclose={helpClose} />
{/if}
{#if view?.phase === "finished" && view.winner && victoryCurtain && !victorySeen}
+462 -222
View File
@@ -1,130 +1,231 @@
<script lang="ts">
import { allCardDefs, RULES_REVISIONS } from "@wizwar/engine";
// The reference desk: how to play, the rules and FAQ as one searchable
// body, the card library with proper entries, the house rulings, the
// story of the table, and the tally. Every section and card has a link
// a player can send; the desk remembers where the reader was.
import { cardDef } from "@wizwar/engine";
import { tick } from "svelte";
import Card from "./Card.svelte";
import Faq from "./Faq.svelte";
import { HOW_TO_PLAY, RULES_SECTIONS } from "./rules";
import { FAQ_GENERAL } from "./faq-general";
import { RULEBOOK_BASE, RULEBOOK_EXPANSION, RULEBOOK_COPYRIGHT } from "./rulebook";
import { HOW_TO_PLAY } from "./rules";
import { RULEBOOK_COPYRIGHT } from "./rulebook";
import { helpState, type HelpTab } from "./help-state";
import {
REFERENCE, SOURCE_LABELS, searchReference, renderInline,
CARD_POOL, searchCards, mentionedCards, sightOf, typeLabel, setLabel,
HOUSE_RULINGS, rulingsFor, searchRulings,
type RefSource,
} from "./reference";
let {
onclose,
initialTab = "play",
initialTab = null,
initialAnchor = null,
initialCard = null,
stats = null,
onstats,
onnavigate,
}: {
onclose: () => void;
initialTab?: "play" | "rules" | "cards" | "rulings" | "about" | "tally";
/** A tab to open on; null reopens where the reader left off. */
initialTab?: HelpTab | null;
/** A section to scroll to on the tab. */
initialAnchor?: string | null;
/** A card entry to open. */
initialCard?: string | null;
stats?: Record<string, number | string | null> | null;
onstats?: () => void;
/** The address bar follows the reader, so the link is always a copy away. */
onnavigate?: (tab: HelpTab, anchor: string | null, card: string | null) => void;
} = $props();
// svelte-ignore state_referenced_locally -- the initial tab is intentionally a one-time value
let tab = $state<"play" | "rules" | "cards" | "rulings" | "about" | "tally">(initialTab);
/** Cards the FAQ has spoken on, in the library's order. */
const ruledCards = allCardDefs().filter((c) => c.faqRulings.length > 0);
let faqCardId = $state<string | null>(null);
let libPeek = $state<string | null>(null);
// svelte-ignore state_referenced_locally -- the opening tab is a one-time value
let tab = $state<HelpTab>(initialCard ? "cards" : (initialTab ?? helpState.tab));
let rulesSearch = $state(helpState.rulesSearch);
let rulingsSearch = $state(helpState.rulingsSearch);
let cardText = $state(helpState.cards.text);
let cardSet = $state(helpState.cards.set);
let cardType = $state(helpState.cards.type);
let cardSight = $state(helpState.cards.sight);
// svelte-ignore state_referenced_locally -- the opening card is a one-time value
let entry = $state<string | null>(initialCard);
let entryOpener: HTMLElement | null = null;
let bodyEl = $state<HTMLElement | null>(null);
let copied = $state<string | null>(null);
function openTally() {
tab = "tally";
onstats?.();
const refResults = $derived(searchReference(rulesSearch));
const rulingResults = $derived(searchRulings(rulingsSearch));
const cardResults = $derived(searchCards({ text: cardText, set: cardSet, type: cardType, sight: cardSight }));
const entryDef = $derived(entry ? cardDef(entry) : null);
const entryRulings = $derived(entry ? rulingsFor(entry) : []);
const entryMentions = $derived(entry ? mentionedCards(entry) : []);
const refBySource = $derived(
(["summary", "rulebook", "expansion", "faq"] as RefSource[])
.map((source) => ({ source, sections: REFERENCE.filter((s) => s.source === source) })),
);
$effect(() => { helpState.tab = tab; });
$effect(() => { helpState.rulesSearch = rulesSearch; });
$effect(() => { helpState.rulingsSearch = rulingsSearch; });
$effect(() => { helpState.cards = { text: cardText, set: cardSet, type: cardType, sight: cardSight }; });
$effect(() => { onnavigate?.(tab, null, entry); });
function saveScroll() {
if (bodyEl) helpState.scroll[tab] = bodyEl.scrollTop;
}
async function goTab(t: HelpTab) {
if (t === tab) return;
saveScroll();
tab = t;
if (t === "tally") onstats?.();
await tick();
if (bodyEl) bodyEl.scrollTop = helpState.scroll[t] ?? 0;
}
async function jumpTo(anchor: string) {
await tick();
const el = bodyEl?.querySelector(`#${CSS.escape(anchor)}`);
el?.scrollIntoView({ block: "start" });
onnavigate?.(tab, anchor, null);
}
function linkFor(t: HelpTab, anchor: string | null, card: string | null): string {
const q = card ? `card=${card}` : `help=${t}${anchor ? `/${anchor}` : ""}`;
return `${location.origin}/?${q}`;
}
function copyLink(t: HelpTab, anchor: string | null, card: string | null) {
const key = card ?? `${t}/${anchor ?? ""}`;
navigator.clipboard?.writeText(linkFor(t, anchor, card)).catch(() => {});
onnavigate?.(t, anchor, card);
copied = key;
setTimeout(() => { if (copied === key) copied = null; }, 1500);
}
function openEntry(id: string, opener?: EventTarget | null) {
entryOpener = (opener instanceof HTMLElement ? opener : document.activeElement) as HTMLElement | null;
entry = id;
}
function closeEntry() {
entry = null;
entryOpener?.focus();
entryOpener = null;
}
function close() {
saveScroll();
onclose();
}
/** Escape closes the foremost layer only: the card entry before the desk. */
function onkeydown(e: KeyboardEvent) {
if (e.key !== "Escape") return;
if (entry) { closeEntry(); return; }
close();
}
function n(v: unknown): string {
return Number(v ?? 0).toLocaleString();
}
function hours(mins: number): string {
return mins < 90 ? `${mins} minutes` : `${Math.round(mins / 6) / 10} hours`;
return mins < 90 ? `${n(mins)} minutes` : `${n(Math.round(mins / 6) / 10)} hours`;
}
let search = $state("");
// The playable pool: every card actually in the 6e game (base + Exp1).
const pool = allCardDefs().filter(
(d) => (d.set === "basic" || d.set === "expansion1") && (d.quantity ?? 0) > 0,
);
const filtered = $derived.by(() => {
const q = search.trim().toLowerCase();
const list = q
? pool.filter(
(d) => d.name.toLowerCase().includes(q) || (d.text ?? "").toLowerCase().includes(q),
)
: pool;
return [...list].sort((a, b) => a.name.localeCompare(b.name));
$effect(() => {
if (initialAnchor) void jumpTo(initialAnchor);
else if (bodyEl) bodyEl.scrollTop = helpState.scroll[tab] ?? 0;
});
function onkeydown(e: KeyboardEvent) {
if (e.key === "Escape") onclose();
}
</script>
<svelte:window {onkeydown} />
{#if libPeek}
<div class="lib-peek-scrim" role="button" tabindex="-1" onclick={() => (libPeek = null)} onkeydown={() => {}}>
<div class="lib-peek">
<Card card={{ instanceId: `peek-${libPeek}`, cardId: libPeek }} onfaq={(id) => (faqCardId = id)} />
{#if entryDef}
<div class="layer-scrim" role="presentation" onclick={closeEntry}>
<div class="entry" role="dialog" aria-modal="true" aria-label="{entryDef.name}, card entry" tabindex="-1"
onclick={(e) => e.stopPropagation()} onkeydown={() => {}}>
<header class="entry-head">
<span class="entry-title">{entryDef.name}</span>
<button class="link-btn" onclick={() => copyLink("cards", null, entryDef!.id)} title="copy a link to this card">
{copied === entryDef.id ? "link copied" : "🔗 link"}</button>
<button class="close" onclick={closeEntry} aria-label="close card entry">×</button>
</header>
<div class="entry-body">
<div class="entry-card">
<Card card={{ instanceId: `entry-${entryDef.id}`, cardId: entryDef.id }} />
</div>
<div class="entry-text reading">
<p class="entry-meta">
{typeLabel(entryDef)} · {sightOf(entryDef)} · {setLabel(entryDef)}
</p>
<h3>As printed</h3>
<p class="printed">{entryDef.text ?? (entryDef.cardType === "number" ? `A NUMBER card worth ${entryDef.value}: add it to your movement, or set a spell's duration or power.` : "")}</p>
{#if entryDef.faqRulings.length > 0}
<h3>Official FAQ</h3>
{#each entryDef.faqRulings as q, i (i)}<p>{q}</p>{/each}
<p class="colophon">Tom Jolly's own rulings (wizwar.com, September 2002). The cards overrule the rules, and the designer overrules the table.</p>
{/if}
{#if entryRulings.length > 0}
<h3>At this table</h3>
{#each entryRulings as r (r.id)}
{#each r.body as p, i (i)}<p>{p}</p>{/each}
<p class="colophon"><button class="inline-link" onclick={() => { closeEntry(); void goTab("rulings"); void jumpTo(`ruling-${r.id}`); }}>{r.title}, in the house rulings</button></p>
{/each}
{/if}
{#if entryMentions.length > 0}
<h3>Mentions</h3>
<p class="chips">
{#each entryMentions as m (m.id)}
<button class="chip" onclick={(e) => openEntry(m.id, e.currentTarget)}>{m.name}</button>
{/each}
</p>
{/if}
</div>
</div>
</div>
</div>
{/if}
{#if faqCardId}
<Faq cardId={faqCardId} onclose={() => (faqCardId = null)} />
{/if}
<div class="scrim" role="button" tabindex="-1" onclick={onclose} onkeydown={() => {}}>
<div
class="booklet"
role="dialog"
aria-modal="true"
aria-label="help"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={() => {}}
>
<div class="scrim" role="presentation" onclick={close}>
<div class="booklet" role="dialog" aria-modal="true" aria-label="help" tabindex="-1"
onclick={(e) => e.stopPropagation()} onkeydown={() => {}}>
<header class="booklet-head">
<span class="booklet-title">Wiz-War</span>
<nav class="tabs">
<button class:current={tab === "play"} onclick={() => (tab = "play")}>How to play</button>
<button class:current={tab === "rules"} onclick={() => (tab = "rules")}>The rules</button>
<button class:current={tab === "cards"} onclick={() => (tab = "cards")}>Card library</button>
<button class:current={tab === "rulings"} onclick={() => (tab = "rulings")}>House rulings</button>
<button class:current={tab === "about"} onclick={() => (tab = "about")}>About</button>
<button class:current={tab === "tally"} onclick={openTally}>The tally</button>
<nav class="tabs" aria-label="help sections">
<button class:current={tab === "play"} onclick={() => goTab("play")}>How to play</button>
<button class:current={tab === "rules"} onclick={() => goTab("rules")}>The rules</button>
<button class:current={tab === "cards"} onclick={() => goTab("cards")}>Card library</button>
<button class:current={tab === "rulings"} onclick={() => goTab("rulings")}>House rulings</button>
<button class:current={tab === "about"} onclick={() => goTab("about")}>About</button>
<button class:current={tab === "tally"} onclick={() => goTab("tally")}>The tally</button>
</nav>
<button class="close" onclick={onclose} aria-label="close help">×</button>
<button class="close" onclick={close} aria-label="close help">×</button>
</header>
<div class="booklet-body">
<div class="booklet-body" bind:this={bodyEl}>
{#if tab === "tally"}
<div class="tally">
<div class="reading">
<h3>How much love the maze is getting</h3>
{#if stats}
<div class="stories">
<div class="story"><span class="big">{n(stats.gamesFinished)}</span><span>games fought to a finish</span></div>
<div class="story"><span class="big">{n(stats.winsByTreasure)}</span><span>won by carrying treasure home</span></div>
<div class="story"><span class="big">{n(stats.winsByLastStanding)}</span><span>won as the last wizard standing</span></div>
<div class="story"><span class="big">{hours(Number(stats.minutesAtTable))}</span><span>spent at the table</span></div>
</div>
<h3>The ledgers in detail</h3>
<dl class="tally-list">
<dt>{stats.gamesCreated}</dt><dd>games chronicled — {stats.gamesStarted} begun, {stats.gamesFinished} fought to a finish</dd>
<dt>{stats.wizardsSeated}</dt><dd>distinct wizards have taken a seat</dd>
<dt>{stats.commandsPlayed}</dt><dd>spells, steps, and punches recorded in the ledgers</dd>
<dt>{hours(Number(stats.minutesAtTable))}</dt><dd>spent at the table, by the clock between moves</dd>
<dt>{stats.winsByTreasure} / {stats.winsByLastStanding}</dt><dd>victories by treasure-theft / by last wizard standing</dd>
<dt>{stats.longestGameCommands}</dt><dd>actions in the longest game yet played — every step, spell, and pass in its ledger</dd>
<dt>{stats.fullestTable}</dt><dd>wizards at the fullest table</dd>
<dt>{stats.hotseatGames}</dt><dd>of the games were hotseat tables, reporting in anonymously</dd>
<dt>{stats.automatonGames ?? 0}</dt><dd>games fought against the clockwork</dd>
<dt>{n(stats.gamesCreated)}</dt><dd>games chronicled — {n(stats.gamesStarted)} begun</dd>
<dt>{n(stats.wizardsSeated)}</dt><dd>distinct wizard names have taken a seat</dd>
<dt>{n(stats.commandsPlayed)}</dt><dd>spells, steps, and punches recorded</dd>
<dt>{n(stats.longestGameCommands)}</dt><dd>actions in the longest game yet played</dd>
<dt>{n(stats.fullestTable)}</dt><dd>wizards at the fullest table</dd>
<dt>{n(stats.hotseatGames)}</dt><dd>games played on one device, passed around</dd>
<dt>{n(stats.automatonGames ?? 0)}</dt><dd>games fought against the clockwork</dd>
</dl>
{#if stats.firstGameAt}
<p class="colophon">The first game was dealt {new Date(String(stats.firstGameAt)).toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" })}. Hotseat tables send only counts — names and moves stay on the device.</p>
{/if}
<p class="colophon">
{#if stats.firstGameAt}The first game was dealt {new Date(String(stats.firstGameAt)).toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" })}.{/if}
Wizards are counted by name, not by person: one player under two names is two wizards here.
Table time is estimated from the clock between moves, so a game left open overnight counts only the minutes it was actually played.
Games on one device send only their counts — names and moves stay on the device.
</p>
{:else}
<p>Counting the ledgers…</p>
{/if}
</div>
{:else if tab === "about"}
<div class="about">
<h3>What this is</h3>
<p>
Wiz-War is a game of magical combat in a stone labyrinth: two to six
wizards prowl a maze, hurling fireballs, walking through walls,
summoning trolls, and stealing each other's treasure. It was created
by <strong>Tom Jolly</strong> in 1983 and first published under his
own Jolly Games label; the edition reproduced here is the
<strong>sixth edition</strong>, published by Chessex in 1993, together
with its one expansion — monsters and magic wands included.
</p>
<div class="reading about">
<h3>Why it exists</h3>
<p>
In the early nineties, a group of friends played this exact edition
@@ -138,6 +239,16 @@
it was played at that table, made so the same friends — and their
friends — can keep playing it.
</p>
<h3>What this is</h3>
<p>
Wiz-War is a game of magical combat in a stone labyrinth: two to six
wizards prowl a maze, hurling fireballs, walking through walls,
summoning trolls, and stealing each other's treasure. It was created
by <strong>Tom Jolly</strong> in 1983 and first published under his
own Jolly Games label; the edition reproduced here is the
<strong>sixth edition</strong>, published by Chessex in 1993, together
with its one expansion — monsters and magic wands included.
</p>
<h3>Whose game it is</h3>
<p>
Wiz-War is Tom Jolly's design, and the rights to it now rest with
@@ -151,22 +262,35 @@
somebody at a real table.
</p>
<h3>Behind the curtain</h3>
<p>
The workshops where this table's pieces are made are open to
visitors:
the <a href="/?tokens">token workshop</a> shows every token in both
arts beside the wall textures and spell sprites;
the <a href="/?fx">flourish workshop</a> plays each board effect on
demand; and
the <a href="/?fpv">first-person workshop</a> walks the maze through
a wizard's own eyes (add <code>&demo=1</code> to watch a reel).
</p>
<p>The workshops where this table's pieces are made are open to visitors.</p>
<div class="gallery">
<a class="tile" href="/?tokens">
<img src="/tokens/alter-ego.png" alt="" />
<span class="tile-name">The token workshop</span>
<span class="tile-note">every token in both arts, beside the wall textures and spell sprites</span>
</a>
<a class="tile" href="/?fx">
<img src="/fx3d/fireball.png" alt="" />
<span class="tile-name">The flourish workshop</span>
<span class="tile-note">each board effect, played on demand</span>
</a>
<a class="tile" href="/?fpv">
<img src="/hero.jpg" alt="" />
<span class="tile-name">The first-person workshop</span>
<span class="tile-note">the maze through a wizard's own eyes — or <span class="tile-link">watch the demo reel</span></span>
</a>
</div>
<p class="colophon"><a href="/?fpv&demo=1">Watch the demo</a>: two clockwork wizards play a stretch, then the reel replays it.</p>
<h3>What the table remembers</h3>
<p>
Every game here is recorded permanently, move by move table talk
included — so finished games can be replayed, shared, and studied.
Play under whatever name you like, but know that what you say and
do at the table becomes part of its lasting record.
An online game is written to this table's ledger move by move, table
talk included, and kept: a finished game can be replayed from above
or through a wizard's own eyes, and any turn can be shared by link.
A shared replay shows that turn's moves and the sharing wizard's view
of the board — never anyone's hand. A game passed around one device
stays on that device; only its counts reach the tally. Play under
whatever name you like, and know that what you say and do at an
online table is part of its record.
</p>
<h3>Send word</h3>
<p>
@@ -183,113 +307,139 @@
</p>
</div>
{:else if tab === "play"}
{#each HOW_TO_PLAY as section (section.title)}
<h3>{section.title}</h3>
{#each section.body as p, i (i)}<p>{p}</p>{/each}
{/each}
{:else if tab === "rulings"}
<p class="colophon">
For players who know the box. Where this table departs from the
cardboard, and the calls it makes where the rulebook is silent.
Every game is frozen at the revision it was dealt under, so an old
game replays exactly as it was played. If the table rules against
your memory of the rules, the 🐞 report button pins the moment
for review — the reply lands in your lobby.
</p>
<h3>What the digital table does differently</h3>
<p>THE THUMB OF GOD is a divine meteor. Aim it at a square; the die
drifts up to two squares in a random direction, then every token in
and around the landing square — objects, treasures, creatures, even
wizards — is flung to a random nearby square. Walls mean nothing to
falling cardboard, and there is no counteraction.</p>
<p>An ILLUSION WALL is real only to those who believe it. Its creator
sees through it from the start; everyone else sees stone until they
walk into it or see through it, and the maze remembers each
wizard's verdict separately. An untested illusion shimmers faintly.</p>
<p>An ambush (OPPORTUNITY FIRE) is set with the attack card it will
fire and a trigger of your choice: an opponent entering your line of
sight, coming within one square, or picking up any treasure. It
springs on their turn, out of yours.</p>
<p>BUTT-HEAD's ram is measured as the shortest walk through the
corridors from where you cast it to your victim's square, on the
legs you have this turn; you cannot pad the blow by taking the
long way round, and the goat lands on the victim's square. There
is no ceiling. MAD DASH doubles the whole allowance, NUMBER cards
included, so a well-placed goat can ram for sixteen.</p>
<p>Spells cast at a FILL SQUARE WITH SLIME lodge in the gel, and a
slime may hold several. They go off one at a time, oldest first:
each wizard who pushes in springs one spell, the next wizard the
next. The card says only that each spell goes off once; the queue
is this table's reading. A slime shows how many it holds, and a
peek names them, since every cast into it was seen.</p>
<p>Clockwork wizards — the automatons — play from the same redacted
view a human seat gets and play by the rules the engine enforces on
everyone; a tier changes what they draw and know.</p>
<p>A turn need not be taken at once. Games wait on the lobby ledger
for days; the browser holds your seat; a replay of any game can be
watched from above or through a wizard's eyes and shared by link.</p>
<h3>Rulings by revision</h3>
<p class="colophon">Each revision below changed how something resolves; games dealt before it keep the old reading.</p>
<dl class="revisions">
{#each RULES_REVISIONS as r (r.rev)}
<dt>Rev {r.rev}</dt><dd>{r.note}</dd>
<div class="reading">
{#each HOW_TO_PLAY as section (section.title)}
<h3 id="play-{section.title.toLowerCase().replace(/[^a-z0-9]+/g, '-')}">{section.title}</h3>
{#each section.body as p, i (i)}<p>{p}</p>{/each}
{/each}
</dl>
<h3>Card rulings from the FAQ</h3>
<p class="colophon">{ruledCards.length} cards carry rulings from the official FAQ; the engine follows them.</p>
{#each ruledCards as c (c.id)}
<details class="ruling">
<summary>{c.name}</summary>
{#each c.faqRulings as q, i (i)}<p>{q}</p>{/each}
</details>
{/each}
</div>
{:else if tab === "rulings"}
<div class="reading">
<p class="colophon">
For players who know the box: how this table resolves what the
cardboard leaves to the players, by card. If the table rules
against your memory of the rules, the 🐞 report button pins the
moment for review — the reply lands in your lobby.
</p>
<div class="search-row">
<input bind:value={rulingsSearch} placeholder="search the rulings by card or topic…" aria-label="search house rulings" />
</div>
{#if rulingsSearch.trim()}
<p class="count">{rulingResults.length === 0 ? `Nothing here mentions "${rulingsSearch.trim()}".` : `${rulingResults.length} of ${HOUSE_RULINGS.length} rulings`}</p>
{/if}
{#each rulingResults as r (r.id)}
<h3 id="ruling-{r.id}" class="linked">
{r.title}
<button class="link-btn" onclick={() => copyLink("rulings", `ruling-${r.id}`, null)} title="copy a link to this ruling">
{copied === `rulings/ruling-${r.id}` ? "copied" : "🔗"}</button>
</h3>
{#each r.body as p, i (i)}<p>{p}</p>{/each}
<p class="chips">
{#each r.cards as id (id)}
<button class="chip" onclick={(e) => openEntry(id, e.currentTarget)}>{cardDef(id).name}</button>
{/each}
</p>
{/each}
{#if !rulingsSearch.trim()}
<h3 id="ruling-the-table">The table itself</h3>
<p>Clockwork wizards — the automatons — play from the same redacted
view a human seat gets and play by the rules the engine enforces on
everyone; a tier changes what they draw and know.</p>
<p>A turn need not be taken at once. Games wait on the lobby ledger
for days; the browser holds your seat; a replay of any game can be
watched from above or through a wizard's eyes and shared by link.</p>
{/if}
</div>
{:else if tab === "rules"}
<p class="colophon">
Sixth edition rules, from the original rulebook. © 1985 Jolly Games —
this digital adaptation is a fan project.
</p>
{#each RULES_SECTIONS as section (section.title)}
<h3>{section.title}</h3>
{#each section.body as p, i (i)}<p>{p}</p>{/each}
{/each}
<h2 class="faq-divider">The rulebook, verbatim</h2>
<p class="colophon">
The full text, word for word, for settling table disputes. {RULEBOOK_COPYRIGHT}
</p>
{#each RULEBOOK_BASE as section (section.title)}
<h3>{section.title}</h3>
{#each section.paragraphs as p, i (i)}<p>{p}</p>{/each}
{/each}
<h2 class="faq-divider">Expansion Set 1 — verbatim</h2>
{#each RULEBOOK_EXPANSION as section (section.title)}
<h3>{section.title}</h3>
{#each section.paragraphs as p, i (i)}<p>{p}</p>{/each}
{/each}
<h2 class="faq-divider">Official FAQ — general rulings</h2>
<p class="colophon">
Tom Jolly's own answers (wizwar.com, September 2002), verbatim. Rulings
occasionally reference other editions. Card-specific rulings sit on the
cards themselves — look for the FAQ seal.
</p>
{#each FAQ_GENERAL as section (section.title)}
<h3>{section.title}</h3>
{#each section.body as p, i (i)}<p>{p}</p>{/each}
{/each}
<div class="reading">
<div class="search-row">
<input bind:value={rulesSearch} placeholder="search the rules, the rulebook, the expansion, and the FAQ…" aria-label="search the rules" />
</div>
{#if rulesSearch.trim()}
<p class="count">{refResults.length === 0 ? `Nothing in the rules mentions "${rulesSearch.trim()}".` : `${refResults.length} of ${REFERENCE.length} sections`}</p>
{#each refResults as s (s.id)}
<h3 id={s.id} class="linked">
{s.title}
<span class="source">{SOURCE_LABELS[s.source]}</span>
<button class="link-btn" onclick={() => copyLink("rules", s.id, null)} title="copy a link to this section">
{copied === `rules/${s.id}` ? "copied" : "🔗"}</button>
</h3>
{#each s.paragraphs as p, i (i)}<p>{@html renderInline(p)}</p>{/each}
{/each}
{:else}
<nav class="contents" aria-label="contents">
{#each refBySource as group (group.source)}
<div class="contents-group">
<span class="contents-head">{SOURCE_LABELS[group.source]}</span>
{#each group.sections as s (s.id)}
<button class="contents-link" onclick={() => jumpTo(s.id)}>{s.title}</button>
{/each}
</div>
{/each}
</nav>
{#each refBySource as group (group.source)}
<h2 class="divider" id="source-{group.source}">{SOURCE_LABELS[group.source]}</h2>
{#if group.source === "summary"}
<p class="colophon">Sixth edition rules, condensed from the original rulebook. © 1985 Jolly Games — this digital adaptation is a fan project.</p>
{:else if group.source === "rulebook"}
<p class="colophon">The full text, word for word, for settling table disputes. {RULEBOOK_COPYRIGHT} Notes marked <span class="ed-note">(6E: …)</span> are this table's, on where the sixth edition differs.</p>
{:else if group.source === "faq"}
<p class="colophon">Tom Jolly's own answers (wizwar.com, September 2002), verbatim. Rulings occasionally reference other editions. Card-specific rulings are on the cards themselves, in the library.</p>
{/if}
{#each group.sections as s (s.id)}
<h3 id={s.id} class="linked">
{s.title}
<button class="link-btn" onclick={() => copyLink("rules", s.id, null)} title="copy a link to this section">
{copied === `rules/${s.id}` ? "copied" : "🔗"}</button>
</h3>
{#each s.paragraphs as p, i (i)}<p>{@html renderInline(p)}</p>{/each}
{/each}
{/each}
{/if}
</div>
{:else}
<div class="search-row">
<input
bind:value={search}
placeholder="search {pool.length} cards…"
aria-label="search cards"
/>
<input bind:value={cardText} placeholder="search {CARD_POOL.length} cards by name or text…" aria-label="search cards" />
</div>
<div class="filters">
<label>set
<select bind:value={cardSet}>
<option value="all">all</option>
<option value="basic">base deck</option>
<option value="expansion1">expansion</option>
</select>
</label>
<label>type
<select bind:value={cardType}>
<option value="all">all</option>
<option value="attack">attack</option>
<option value="neutral">neutral</option>
<option value="counteraction">counteraction</option>
<option value="number">number</option>
<option value="object">object</option>
<option value="trap">trap</option>
</select>
</label>
<label>target
<select bind:value={cardSight}>
<option value="all">any</option>
<option value="los">line of sight</option>
<option value="adjacent">adjacent</option>
<option value="none">no target</option>
</select>
</label>
<span class="count">{cardResults.length === 0 ? "No cards found." : `${cardResults.length} card${cardResults.length === 1 ? "" : "s"}`}</span>
</div>
{#if cardResults.length === 0}
<p class="colophon">No card matches {cardText.trim() ? `"${cardText.trim()}"` : "those filters"}. Try fewer words, or widen the filters.</p>
{/if}
<div class="card-grid">
{#each filtered as def (def.id)}
{#each cardResults as def (def.id)}
<div class="card-slot">
<Card
card={{ instanceId: `lib-${def.id}`, cardId: def.id }}
onclick={() => (libPeek = def.id)}
onfaq={(id) => (faqCardId = id)}
onclick={() => openEntry(def.id)}
onfaq={() => openEntry(def.id)}
/>
<span class="card-meta">
×{def.quantity}{def.alsoIn?.length ? ` (+${def.alsoIn[0]!.quantity} exp)` : ""}
@@ -340,7 +490,7 @@
text-transform: uppercase;
letter-spacing: 0.04em;
}
.tabs { display: flex; gap: 0.3rem; }
.tabs { display: flex; flex-wrap: wrap; gap: 0.3rem; }
.tabs button {
font-family: "Oswald", sans-serif;
font-size: 0.72rem;
@@ -368,6 +518,12 @@
line-height: 1;
}
.close:hover { color: #b3372b; }
/* A narrow screen wraps the tabs to their own rows under the title and the close. */
@media (max-width: 640px) {
.booklet-head { gap: 0.5rem 1rem; }
.tabs { order: 3; flex-basis: 100%; }
.close { order: 2; }
}
.booklet-body {
overflow-y: auto;
@@ -376,7 +532,9 @@
font-size: 0.98rem;
line-height: 1.5;
}
.faq-divider {
/* Long reading gets a book's column: narrower, a touch larger. */
.reading { max-width: 44rem; margin: 0 auto; font-size: 1.04rem; line-height: 1.55; }
.divider {
font-family: "Oswald", sans-serif;
font-size: 1rem;
letter-spacing: 0.1em;
@@ -385,7 +543,7 @@
padding-top: 0.8rem;
margin: 1.6rem 0 0.2rem;
}
.booklet-body h3 {
.booklet-body h3, .entry-text h3 {
font-family: "Oswald", sans-serif;
font-size: 0.85rem;
letter-spacing: 0.12em;
@@ -394,18 +552,63 @@
padding-bottom: 0.15rem;
margin: 1.2rem 0 0.4rem;
}
.booklet-body h3:first-child { margin-top: 0; }
.revisions { margin: 0.4rem 0 1rem; }
.revisions dt { font-family: "Oswald", sans-serif; letter-spacing: 0.08em; text-transform: uppercase; font-size: 0.75rem; margin-top: 0.6rem; }
.revisions dd { margin: 0.1rem 0 0; }
.revisions dd::first-letter { text-transform: uppercase; }
.ruling { margin: 0.3rem 0; }
.ruling summary { cursor: pointer; font-weight: 600; }
.ruling p { margin: 0.3rem 0 0.5rem 1rem; }
.reading > h3:first-child, .entry-text > h3:first-child { margin-top: 0; }
h3.linked { display: flex; align-items: baseline; gap: 0.5rem; scroll-margin-top: 0.5rem; }
.source { font-size: 0.65rem; letter-spacing: 0.08em; color: #8a7a5e; border: 1px solid #c9bd9f; border-radius: 3px; padding: 0 0.3rem; text-transform: none; }
.link-btn {
margin-left: auto;
background: none;
border: 1px solid transparent;
border-radius: 3px;
color: #8a7a5e;
font-family: "Oswald", sans-serif;
font-size: 0.62rem;
letter-spacing: 0.08em;
text-transform: uppercase;
cursor: pointer;
padding: 0.1rem 0.35rem;
opacity: 0.6;
}
h3:hover .link-btn, .link-btn:focus-visible, .entry-head .link-btn { opacity: 1; }
.link-btn:hover { border-color: #b3a687; color: #43331f; }
.booklet-body p { margin: 0.35rem 0; }
.colophon { font-style: italic; color: #6b5a41; font-size: 0.85rem; }
.booklet-body a { color: #8a4a1f; text-decoration-style: dotted; }
.booklet-body a:hover { color: #43331f; }
.colophon { font-style: italic; color: #6b5a41; font-size: 0.88rem; }
.count { font-family: "Courier Prime", monospace; font-size: 0.8rem; color: #6b5a41; }
.booklet-body a, .inline-link { color: #8a4a1f; text-decoration: underline; text-decoration-style: dotted; }
.booklet-body a:hover, .inline-link:hover { color: #43331f; }
.inline-link { background: none; border: none; padding: 0; font: inherit; cursor: pointer; }
.booklet-body :global(.ed-note) {
font-style: italic;
color: #5e4d33;
background: rgba(179, 166, 135, 0.22);
border-left: 2px solid #b3a687;
padding: 0 0.3rem;
}
.contents { columns: 2; column-gap: 2rem; margin: 0.6rem 0 0.4rem; font-size: 0.92rem; }
.contents-group { break-inside: avoid; margin-bottom: 0.7rem; }
.contents-head { display: block; font-family: "Oswald", sans-serif; font-size: 0.7rem; letter-spacing: 0.12em; text-transform: uppercase; color: #8a7a5e; margin-bottom: 0.15rem; }
.contents-link { display: block; background: none; border: none; padding: 0.05rem 0; font: inherit; color: #8a4a1f; cursor: pointer; text-align: left; }
.contents-link:hover { color: #43331f; text-decoration: underline; }
.chips { display: flex; flex-wrap: wrap; gap: 0.3rem; }
.chip {
font-family: "Oswald", sans-serif;
font-size: 0.68rem;
letter-spacing: 0.08em;
text-transform: uppercase;
background: #e4dbc2;
border: 1px solid #b3a687;
border-radius: 3px;
color: #43331f;
padding: 0.15rem 0.45rem;
cursor: pointer;
}
.chip:hover { background: #d8cdae; }
.stories { display: grid; grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); gap: 0.8rem; margin: 0.6rem 0 1rem; }
.story { display: flex; flex-direction: column; align-items: center; text-align: center; background: #e4dbc2; border: 1px solid #b3a687; border-radius: 4px; padding: 0.6rem 0.5rem; font-size: 0.9rem; }
.big { font-family: "Oswald", sans-serif; font-weight: 700; font-size: 1.7rem; color: #b3372b; line-height: 1.1; }
.tally-list { display: grid; grid-template-columns: auto 1fr; gap: 0.35rem 0.8rem; margin: 0.6rem 0 1rem; }
.tally-list dt {
font-family: "Oswald", sans-serif; font-weight: 700; font-size: 1.05rem;
@@ -413,6 +616,14 @@
}
.tally-list dd { margin: 0; align-self: center; }
.gallery { display: grid; grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); gap: 0.8rem; margin: 0.6rem 0; }
.tile { display: flex; flex-direction: column; gap: 0.25rem; background: #e4dbc2; border: 1px solid #b3a687; border-radius: 4px; padding: 0.5rem; text-decoration: none; color: inherit; }
.tile:hover { background: #d8cdae; }
.tile img { width: 100%; aspect-ratio: 16 / 10; object-fit: cover; border-radius: 3px; border: 1px solid #b3a687; background: #2a2622; }
.tile-name { font-family: "Oswald", sans-serif; font-size: 0.8rem; letter-spacing: 0.08em; text-transform: uppercase; }
.tile-note { font-size: 0.85rem; color: #6b5a41; }
.tile-link { text-decoration: underline; text-decoration-style: dotted; }
.search-row { margin-bottom: 0.8rem; }
.search-row input {
width: 100%;
@@ -425,6 +636,9 @@
font-size: 0.95rem;
color: #43331f;
}
.filters { display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem 1rem; margin: -0.3rem 0 0.8rem; font-family: "Oswald", sans-serif; font-size: 0.68rem; letter-spacing: 0.1em; text-transform: uppercase; color: #6b5a41; }
.filters select { font-family: "Archivo Narrow", sans-serif; font-size: 0.9rem; text-transform: none; letter-spacing: 0; margin-left: 0.3rem; background: #f6f0df; border: 1px solid #b3a687; border-radius: 3px; color: #43331f; padding: 0.15rem 0.3rem; }
.filters .count { margin-left: auto; text-transform: none; letter-spacing: 0; }
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(8.4rem, 1fr));
@@ -432,25 +646,51 @@
justify-items: center;
}
.card-slot { display: flex; flex-direction: column; align-items: center; gap: 0.25rem; }
.card-slot :global(.card) { cursor: default; }
.card-slot :global(.card:hover) { transform: none; box-shadow: 0 3px 8px rgba(10, 8, 4, 0.45); }
.lib-peek-scrim {
.card-slot :global(.card:hover) { transform: translateY(-0.2rem); }
.card-meta {
font-family: "Courier Prime", monospace;
font-size: 0.68rem;
color: #6b5a41;
}
/* The card entry: the familiar card beside reading-size text. */
.layer-scrim {
position: fixed;
inset: 0;
background: rgba(10, 12, 16, 0.55);
display: grid;
place-items: center;
z-index: 55;
padding: 1.5rem 1rem;
}
.lib-peek :global(.card) {
transform: scale(2.1);
cursor: default;
.entry {
background: #efe8d4;
color: #43331f;
width: min(46rem, 100%);
max-height: calc(100vh - 3rem);
border-radius: 6px;
border: 1px solid #b3a687;
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.7);
display: flex;
flex-direction: column;
overflow: hidden;
}
.lib-peek :global(.card:hover) { transform: scale(2.1); }
.card-meta {
font-family: "Courier Prime", monospace;
font-size: 0.68rem;
color: #6b5a41;
.entry-head { display: flex; align-items: center; gap: 0.8rem; padding: 0.6rem 1rem; border-bottom: 2px solid #43331f; }
.entry-title { font-family: "Oswald", sans-serif; font-weight: 700; font-size: 1.05rem; text-transform: uppercase; letter-spacing: 0.04em; }
.entry-head .link-btn { margin-left: 0; }
.entry-body { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 1.2rem; padding: 1rem 1.2rem 1.2rem; overflow-y: auto; font-family: "Archivo Narrow", sans-serif; }
.entry-card { align-self: start; position: sticky; top: 0; }
.entry-card :global(.card) { transform: scale(1.35); transform-origin: top left; margin: 0 2.9rem 4rem 0; cursor: default; }
.entry-card :global(.card:hover) { transform: scale(1.35); box-shadow: 0 3px 8px rgba(10, 8, 4, 0.45); }
.entry-text { min-width: 0; }
.entry-text p { margin: 0.35rem 0; }
.entry-meta { font-family: "Courier Prime", monospace; font-size: 0.8rem; color: #6b5a41; margin: 0 0 0.4rem; }
.printed { font-size: 1.05rem; }
@media (max-width: 640px) {
.entry-body { grid-template-columns: 1fr; }
.entry-card { position: static; display: flex; justify-content: center; }
.entry-card :global(.card) { transform: none; margin: 0; }
.entry-card :global(.card:hover) { transform: none; }
.contents { columns: 1; }
}
</style>
+21
View File
@@ -0,0 +1,21 @@
// What the help remembers between openings: the tab, each tab's search,
// the library's filters, and where the reader was on each page. Module
// state, so it lasts the session and costs nothing to restore.
import type { CardQuery } from "./reference";
export type HelpTab = "play" | "rules" | "cards" | "rulings" | "about" | "tally";
export const helpState: {
tab: HelpTab;
rulesSearch: string;
rulingsSearch: string;
cards: CardQuery;
scroll: Partial<Record<HelpTab, number>>;
} = {
tab: "play",
rulesSearch: "",
rulingsSearch: "",
cards: { text: "", set: "all", type: "all", sight: "all" },
scroll: {},
};
+296
View File
@@ -0,0 +1,296 @@
// The reference desk behind the help: the rules and FAQ as one searchable,
// linkable body; the house rulings keyed to the cards they touch; and the
// small text services (inline emphasis, cross-references) the help renders
// with. Everything here is data and pure functions; Help.svelte presents it.
import { allCardDefs, cardDef, type CardDef } from "@wizwar/engine";
import { RULES_SECTIONS } from "./rules";
import { RULEBOOK_BASE, RULEBOOK_EXPANSION } from "./rulebook";
import { FAQ_GENERAL } from "./faq-general";
/** A stable anchor from a title: "Line of Sight (L.O.S.)" → "line-of-sight-l-o-s". */
export function slug(title: string): string {
return title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
}
export type RefSource = "summary" | "rulebook" | "expansion" | "faq";
export const SOURCE_LABELS: Record<RefSource, string> = {
summary: "the rules, condensed",
rulebook: "the rulebook, verbatim",
expansion: "Expansion Set 1, verbatim",
faq: "official FAQ",
};
export interface RefSection {
/** Unique across all sources: the source and the title's slug. */
id: string;
source: RefSource;
title: string;
paragraphs: string[];
}
function sections(source: RefSource, list: { title: string; body?: string[]; paragraphs?: string[] }[]): RefSection[] {
return list.map((s) => ({
id: `${source}-${slug(s.title)}`,
source,
title: s.title,
paragraphs: s.body ?? s.paragraphs ?? [],
}));
}
/** Every section of the rules tab, in reading order. */
export const REFERENCE: RefSection[] = [
...sections("summary", RULES_SECTIONS),
...sections("rulebook", RULEBOOK_BASE),
...sections("expansion", RULEBOOK_EXPANSION),
...sections("faq", FAQ_GENERAL),
];
/** The sections whose title or text mention every word of the query. */
export function searchReference(query: string): RefSection[] {
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
if (words.length === 0) return REFERENCE;
return REFERENCE.filter((s) => {
const hay = `${s.title}\n${s.paragraphs.join("\n")}`.toLowerCase();
return words.every((w) => hay.includes(w));
});
}
function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
/** The transcriptions carry light Markdown: **bold** for the rulebook's own
* run-in headings, and *(6E: )* for this table's editorial notes on where
* the sixth edition differs from the text it shipped with. Rendered, with
* the notes set apart from the original words. */
export function renderInline(text: string): string {
return escapeHtml(text)
.replace(/\*\((6E:[\s\S]*?)\)\*/g, '<span class="ed-note">($1)</span>')
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, "$1<em>$2</em>");
}
/** The playable pool: every card in the 6e box and its expansion. */
export const CARD_POOL: CardDef[] = allCardDefs().filter(
(d) => (d.set === "basic" || d.set === "expansion1") && (d.quantity ?? 0) > 0,
);
export type CardTypeFilter = "all" | "attack" | "neutral" | "counteraction" | "number" | "object" | "trap";
export type SetFilter = "all" | "basic" | "expansion1";
export type SightFilter = "all" | "los" | "adjacent" | "none";
export interface CardQuery {
text: string;
set: SetFilter;
type: CardTypeFilter;
sight: SightFilter;
}
function typeMatches(d: CardDef, t: CardTypeFilter): boolean {
if (t === "all") return true;
const kind = d.cardType ?? "";
if (t === "neutral" || t === "counteraction") return kind === t || kind === "neutral/counteraction";
return kind === t;
}
function sightMatches(d: CardDef, s: SightFilter): boolean {
if (s === "all") return true;
if (s === "los") return d.los === true;
if (s === "adjacent") return d.adjacent === true;
return d.los !== true && d.adjacent !== true;
}
/** Cards matching the query, ranked: an exact name first, then names that
* start with the words, then names that contain them, then texts that do.
* Alphabetical within each rank. */
export function searchCards(q: CardQuery): CardDef[] {
const text = q.text.trim().toLowerCase();
const rank = (d: CardDef): number => {
if (!text) return 2;
const name = d.name.toLowerCase();
if (name === text) return 0;
if (name.startsWith(text)) return 1;
if (name.includes(text)) return 2;
if ((d.text ?? "").toLowerCase().includes(text)) return 3;
return -1;
};
return CARD_POOL
.filter((d) => typeMatches(d, q.type) && sightMatches(d, q.sight))
.filter((d) => q.set === "all" || d.set === q.set || d.alsoIn?.some((a) => a.set === q.set))
.map((d) => ({ d, r: rank(d) }))
.filter((x) => x.r >= 0)
.sort((a, b) => a.r - b.r || a.d.name.localeCompare(b.d.name))
.map((x) => x.d);
}
/** Other cards a card's text names in capitals "a NUMBER card" is a
* kind, "MAD DASH" is a card. Longest names first so SLOW DEATH is not
* also read as SLOW. */
export function mentionedCards(cardId: string): CardDef[] {
const def = cardDef(cardId);
let text = def.text ?? "";
if (!text) return [];
text = text.replace(new RegExp(`\\b${def.name.toUpperCase().replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "g"), " ");
const found: CardDef[] = [];
const candidates = CARD_POOL
.filter((d) => d.id !== cardId && d.name.length >= 4 && d.cardType !== "number")
.sort((a, b) => b.name.length - a.name.length);
for (const d of candidates) {
const re = new RegExp(`\\b${d.name.toUpperCase().replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
if (re.test(text)) {
found.push(d);
text = text.replace(new RegExp(re.source, "g"), " ");
}
}
return found.sort((a, b) => a.name.localeCompare(b.name));
}
/** How a card's corner reads: where it must be aimed. */
export function sightOf(d: CardDef): string {
if (d.adjacent) return "adjacent";
if (d.los) return "line of sight";
return "no target";
}
export function typeLabel(d: CardDef): string {
switch (d.cardType) {
case "neutral/counteraction": return "neutral or counteraction";
case null: return "card";
default: return d.cardType;
}
}
export function setLabel(d: CardDef): string {
const also = d.alsoIn?.length ? ` and ×${d.alsoIn[0]!.quantity} in the expansion` : "";
return `×${d.quantity} in the ${d.set === "basic" ? "base deck" : "expansion"}${also}`;
}
/** A house ruling: how this table resolves something the cardboard leaves
* to the players, in the present tense, keyed to the cards it touches. */
export interface HouseRuling {
id: string;
title: string;
cards: string[];
body: string[];
}
export const HOUSE_RULINGS: HouseRuling[] = [
{
id: "thumb-of-god", title: "The Thumb of God", cards: ["thumb-of-god"],
body: ["THE THUMB OF GOD is a divine meteor. Aim it at a square; the die drifts up to two squares in a random direction, then every token in and around the landing square — objects, treasures, creatures, even wizards — is flung to a random nearby square. Walls mean nothing to falling cardboard, and there is no counteraction."],
},
{
id: "illusion-wall", title: "Illusion walls", cards: ["illusion-wall"],
body: ["An ILLUSION WALL is real only to those who believe it. Its creator sees through it from the start; everyone else sees stone until they walk into it or see through it, and the maze remembers each wizard's verdict separately. An untested illusion shimmers faintly, and doubting it is free."],
},
{
id: "ambush", title: "Ambushes", cards: ["opportunity-fire"],
body: ["An ambush (OPPORTUNITY FIRE) is set with the attack card it will fire and a trigger of your choice: an opponent entering your line of sight, coming within one square, or picking up any treasure. It springs on their turn, out of yours."],
},
{
id: "butt-head", title: "The goat's ram", cards: ["butt-head", "mad-dash", "power-run"],
body: [
"BUTT-HEAD's ram is movement. The charge is measured as the shortest walk through the corridors from where you cast it to your victim's square, on the legs you have this turn — three, plus any NUMBER played for movement — and it spends them. You cannot pad the blow by taking the long way round, and the goat lands on the victim's square. There is no ceiling on the damage.",
"MAD DASH doubles the whole allowance, NUMBER cards and POWER RUN points included, so a well-placed goat can ram for sixteen.",
],
},
{
id: "slime", title: "Spells cast into slime", cards: ["fill-square-with-slime"],
body: ["Spells cast at a FILL SQUARE WITH SLIME lodge in the gel, and a slime may hold several. They go off one at a time, oldest first: each wizard who pushes in springs one spell, the next wizard the next. The card says only that each spell goes off once; the queue is this table's reading. A slime shows how many it holds, and a peek names them, since every cast into it was seen."],
},
{
id: "doors-and-sight", title: "Doors and sight", cards: ["pick-lock", "master-key", "remove-lock"],
body: [
"A wizard beside a door they can open — its lock removed, unlocked this turn, or PICK LOCK or MASTER KEY in hand — sees through the doorway. The hallway behind them still cannot.",
"\"The door will relock behind you\" means it: passing through an unlocked door shuts it at the walker's back unless a hand holds it open. A door unlocked and not passed relocks at the turn's end.",
],
},
{
id: "visionstone", title: "Visionstone", cards: ["visionstone", "dust-cloud"],
body: ["VISIONSTONE pierces its one wall for every sight the game asks of its bearer — creations and utility spells included, not only direct attacks. It does not see through a DUST CLOUD."],
},
{
id: "dust-cloud", title: "Dust clouds", cards: ["dust-cloud"],
body: ["A DUST CLOUD blinds whoever stands in it. No line-of-sight spell may be cast from inside a cloud, nor at anyone standing in one, and sight lines that pass through it are blocked. Spells a wizard casts on themself still work."],
},
{
id: "mental-force", title: "Mental Force", cards: ["mental-force"],
body: ["MENTAL FORCE refuses a destination the victim cannot walk to in three spaces, rather than spending the card on nothing."],
},
{
id: "strength", title: "Tearing a treasure away", cards: ["strength"],
body: ["STRENGTH's treasure-tear is an attack: it opens a counteraction window (\"this would be an attack\") rather than resolving on the spot."],
},
{
id: "disease", title: "Disease", cards: ["disease"],
body: ["DISEASE is a plague cast on yourself (\"You're the carrier!\"). The caster carries it; sharing a square bites in both directions; there is no counteraction."],
},
{
id: "fire-imp", title: "The fire imp's scorch", cards: ["fire-imp"],
body: ["The fire imp's scorch is a spell, as the FAQ has it: magical, and counteractable."],
},
{
id: "soulstone", title: "Soulstone", cards: ["soulstone"],
body: ["SOULSTONE's floor holds even when its bearer is at three life-points or below."],
},
{
id: "ward", title: "The ward's bite", cards: ["ward"],
body: ["A WARD's bite is counteractable (\"COUNTERACTIONs ... otherwise work as written\"), though nothing a counter does — a reversal, a reflection — touches the ward's caster."],
},
{
id: "curses", title: "Reversing and reflecting a curse", cards: ["reverse", "full-reflection", "reflection", "slow-death", "walking-dead", "idiot"],
body: [
"A REVERSE against SLOW DEATH or WALKING DEAD turns the whole curse, as the FAQ rules: a point gained per card drawn, half a point per space walked, permanently.",
"A FULL REFLECTION returns a permanent curse — SLOW DEATH, WALKING DEAD, IDIOT — onto its caster instead of letting it evaporate.",
"REFLECTION's returning half is an attack on the caster in its own right, with the caster's own counteraction window: an ABSORB or a BLUNT meets it as it would any blow.",
],
},
{
id: "slow-death", title: "Slow Death's bites", cards: ["slow-death", "absorb", "blunt"],
body: ["SLOW DEATH's per-draw bites land as one blow, which pauses for a victim holding ABSORB or BLUNT. The counter is played against the total: ABSORB soaks up to three, BLUNT halves it rounding up."],
},
{
id: "mad-dash", title: "Mad Dash", cards: ["mad-dash", "power-run"],
body: ["MAD DASH doubles \"NUMBER cards and other add-ons\" too. A number riding the cast fuels it, and numbers or POWER RUN points played under the dash are doubled as well."],
},
{
id: "pits", title: "Crossing a pit", cards: ["create-pit"],
body: [
"Crossing a pit on a 2, 3, or 4 means edging around its rim. The walker lands on an open square beside the pit — the only one if there is one, otherwise the one they name by clicking it. A warp mouth on the rim is a way off like any square, and is taken when no square offers. A pit with no way off cannot be entered.",
],
},
{
id: "big-man", title: "The giant's shove at a fork", cards: ["big-man"],
body: ["BIG MAN at a fork: a pushed wizard leaves by any open side but the way the giant came — the only one if there is one, otherwise the side they choose, the giant's stride hanging until they do. The FAQ gives the choice to the other player; so does this table."],
},
{
id: "waterwall", title: "The waterwall's wave", cards: ["waterwall"],
body: ["A waterwall's wave names its victims before it pushes any of them, so no wizard is caught twice by the same wave. One square into a wall costs one point."],
},
{
id: "lifesaver", title: "Lifesaver", cards: ["lifesaver"],
body: ["A wizard holding LIFESAVER is not eliminated for losing both treasures, exactly as the card promises."],
},
{
id: "force-field", title: "Force Field", cards: ["force-field"],
body: ["FORCE FIELD, after stopping the spell, stands until the end of the opponent's turn: they may not enter its caster's square, nor cast on or past them, on every side — where the card says one side, this table gives all four."],
},
];
/** The rulings that touch a card. */
export function rulingsFor(cardId: string): HouseRuling[] {
return HOUSE_RULINGS.filter((r) => r.cards.includes(cardId));
}
/** Rulings whose title, text, or cards mention every word of the query. */
export function searchRulings(query: string): HouseRuling[] {
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
if (words.length === 0) return HOUSE_RULINGS;
return HOUSE_RULINGS.filter((r) => {
const names = r.cards.map((id) => { try { return cardDef(id).name; } catch { return id; } });
const hay = `${r.title}\n${r.body.join("\n")}\n${names.join("\n")}`.toLowerCase();
return words.every((w) => hay.includes(w));
});
}