Files
wizwar6e/packages/web/src/Help.svelte
T
Eric WagonerandClaude Fable 5 6380fae134 The rulebook itself joins the shelf, verbatim
The rules tab carried a paraphrase and the FAQ; now the court of
final appeal sits between them — the full base rulebook text (the
5th-edition wording the 6th shipped with) and the Expansion Set 1
sections, word for word from the designer's own wizwar.com, with
copyright and provenance shown rather than hidden. This edition has
no Expansion 2 — Artifacts and Transformations do not exist here and
are not included. The text moves out of research/ into real source.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 17:40:00 -04:00

373 lines
14 KiB
Svelte
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.
<script lang="ts">
import { allCardDefs } from "@wizwar/engine";
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";
let {
onclose,
initialTab = "play",
stats = null,
onstats,
}: {
onclose: () => void;
initialTab?: "play" | "rules" | "cards" | "about" | "tally";
stats?: Record<string, number | string | null> | null;
onstats?: () => void;
} = $props();
// svelte-ignore state_referenced_locally -- the initial tab is intentionally a one-time value
let tab = $state<"play" | "rules" | "cards" | "about" | "tally">(initialTab);
let faqCardId = $state<string | null>(null);
let libPeek = $state<string | null>(null);
function openTally() {
tab = "tally";
onstats?.();
}
function hours(mins: number): string {
return mins < 90 ? `${mins} minutes` : `${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));
});
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)} />
</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={() => {}}
>
<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 === "about"} onclick={() => (tab = "about")}>About</button>
<button class:current={tab === "tally"} onclick={openTally}>The tally</button>
</nav>
<button class="close" onclick={onclose} aria-label="close help">×</button>
</header>
<div class="booklet-body">
{#if tab === "tally"}
<div class="tally">
<h3>How much love the maze is getting</h3>
{#if stats}
<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>
</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}
{: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>
<h3>Why it exists</h3>
<p>
In the early nineties, a group of friends played this exact edition
for countless hours — the slammed doors, the ambushes around
corners, the goat charges, the laughter. That box is long out of
print, and its cardboard is worn soft at the edges. This digital
table was built from that very copy: every card was photographed and
transcribed word for word, every board layout verified wall by wall,
and the tokens on this screen are the actual hand-inked art from
those counter sheets. It is a faithful reproduction of the game as
it was played at that table, made so the same friends — and their
friends — can keep playing it.
</p>
<h3>Whose game it is</h3>
<p>
Wiz-War is Tom Jolly's design, and the rights to it now rest with
<strong>Steve Jackson Games</strong>, who publish the current
edition. This project is an unofficial, non-commercial fan work,
made with love and admiration for the original — nothing here is
endorsed by or affiliated with Tom Jolly, Chessex, or Steve Jackson
Games. If this game delights you, honor it the right way:
<a href="https://www.sjgames.com/wiz-war/" target="_blank" rel="noreferrer">buy
the edition currently in print</a>, and deal seven cards to
somebody at a real table.
</p>
<h3>Send word</h3>
<p>
Feedback, bug reports, and faint praise all welcome — ravens fly to
<a href="mailto:eric@ericwagoner.com">eric@ericwagoner.com</a>,
<a href="https://bsky.app/profile/kestrelsnest.social" target="_blank" rel="noreferrer">@kestrelsnest.social</a> on Bluesky,
or <a href="https://toots.kestrelsnest.social/@eric" target="_blank" rel="noreferrer">@eric@toots.kestrelsnest.social</a> on Mastodon.
The keeper of this table roosts at
<a href="https://kestrelsnest.social" target="_blank" rel="noreferrer">kestrelsnest.social</a>.
</p>
<p class="colophon">
Wiz-War © Tom Jolly. Sixth edition published by Chessex, 1993.
Built by Eric and a very enthusiastic AI, 2026.
</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 === "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}
{:else}
<div class="search-row">
<input
bind:value={search}
placeholder="search {pool.length} cards…"
aria-label="search cards"
/>
</div>
<div class="card-grid">
{#each filtered 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)}
/>
<span class="card-meta">
×{def.quantity}{def.alsoIn?.length ? ` (+${def.alsoIn[0]!.quantity} exp)` : ""}
· {def.set === "basic" ? "base" : "exp 1"}
</span>
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>
<style>
.scrim {
position: fixed;
inset: 0;
background: rgba(10, 12, 16, 0.72);
display: grid;
place-items: center;
z-index: 40;
padding: 2rem 1rem;
}
.booklet {
background: #efe8d4;
color: #43331f;
width: min(58rem, 100%);
max-height: calc(100vh - 4rem);
border-radius: 6px;
border: 1px solid #b3a687;
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.6);
display: flex;
flex-direction: column;
overflow: hidden;
}
.booklet-head {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.7rem 1rem;
border-bottom: 2px solid #43331f;
flex-wrap: wrap;
}
.booklet-title {
font-family: "Oswald", sans-serif;
font-weight: 700;
font-size: 1.15rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.tabs { display: flex; gap: 0.3rem; }
.tabs button {
font-family: "Oswald", sans-serif;
font-size: 0.72rem;
letter-spacing: 0.1em;
text-transform: uppercase;
background: none;
border: 1.5px solid transparent;
border-radius: 3px;
color: #6b5a41;
padding: 0.3rem 0.6rem;
cursor: pointer;
}
.tabs button.current {
border-color: #43331f;
color: #43331f;
background: #e4dbc2;
}
.close {
margin-left: auto;
background: none;
border: none;
font-size: 1.4rem;
color: #6b5a41;
cursor: pointer;
line-height: 1;
}
.close:hover { color: #b3372b; }
.booklet-body {
overflow-y: auto;
padding: 1rem 1.4rem 1.4rem;
font-family: "Archivo Narrow", sans-serif;
font-size: 0.98rem;
line-height: 1.5;
}
.faq-divider {
font-family: "Oswald", sans-serif;
font-size: 1rem;
letter-spacing: 0.1em;
text-transform: uppercase;
border-top: 3px double #43331f;
padding-top: 0.8rem;
margin: 1.6rem 0 0.2rem;
}
.booklet-body h3 {
font-family: "Oswald", sans-serif;
font-size: 0.85rem;
letter-spacing: 0.12em;
text-transform: uppercase;
border-bottom: 1px solid #b3a687;
padding-bottom: 0.15rem;
margin: 1.2rem 0 0.4rem;
}
.booklet-body h3:first-child { margin-top: 0; }
.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; }
.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;
color: #b3372b; text-align: right; white-space: nowrap;
}
.tally-list dd { margin: 0; align-self: center; }
.search-row { margin-bottom: 0.8rem; }
.search-row input {
width: 100%;
box-sizing: border-box;
background: #f6f0df;
border: 1px solid #b3a687;
border-radius: 4px;
padding: 0.5rem 0.7rem;
font-family: inherit;
font-size: 0.95rem;
color: #43331f;
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(8.4rem, 1fr));
gap: 0.8rem;
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 {
position: fixed;
inset: 0;
background: rgba(10, 12, 16, 0.55);
display: grid;
place-items: center;
z-index: 55;
}
.lib-peek :global(.card) {
transform: scale(2.1);
cursor: default;
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.7);
}
.lib-peek :global(.card:hover) { transform: scale(2.1); }
.card-meta {
font-family: "Courier Prime", monospace;
font-size: 0.68rem;
color: #6b5a41;
}
</style>