Files
wizwar6e/packages/web/src/reference.ts
T
Eric WagonerandClaude Fable 5.1 80dd7a7865 Rev 23: POWER DRAIN drains the number played; the troll punches walls; the first-person stack fits
POWER DRAIN's gain is the number played, BLUNTed or ABSORBed or not
(FAQ: the counter blunts the damage done, not the drain), and a wall
drained for its points gives them up too. Older games gave only what
the opponent lost and nothing from a wall, and replay so.

"This rock-hard beast can punch a player (or a wall, etc.)": a commanded
troll now punches a wall line beside it for a D4, once a turn — a new
command, so no old game changes.

Under the first-person pane the board strip could run into the dock on
a short or tall window; the pane now takes no more height than the row
can spare and the strip shrinks beneath it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-17 00:07:34 -04:00

309 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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: "teleport", title: "Teleporting across the maze's edge", cards: ["teleport"],
body: ["TELEPORT ignores the maze's outer edge as it ignores any wall. A teleporter leaving the maze at any square's edge re-enters at the opposite edge on the same line, one space on — the lettered openings are not needed. Four spaces straight up from two squares below the top edge lands two squares up from the bottom."],
},
{
id: "power-drain", title: "Power Drain", cards: ["power-drain", "blunt", "absorb"],
body: ["POWER DRAIN drains the number played. The caster gains it whether the blow is BLUNTed or ABSORBed — the FAQ has the counter blunting the damage done, not the drain — and a wall drained for its points gives them up as a wizard would. A FULL SHIELD stops the drain with the spell."],
},
{
id: "troll", title: "The troll's fist", cards: ["troll"],
body: ["A commanded TROLL punches a wall line beside it as it punches a wizard: a D4 of damage toward the wall's fall, once a turn."],
},
{
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));
});
}