Files
wizwar6e/packages/web/src/Help.svelte
T
Eric WagonerandClaude Fable 5 5375941ed3 Every card wears its FAQ seal; library cards enlarge above the booklet
The FAQ affordance moves onto the card itself: a small dark seal in
the corner of any card with official rulings, everywhere cards render
— hand, inspector, peek, final reveal, discard pile, card library —
opening the rulings modal on tap (the link, never the text: card
faces stay verbatim card text). The separate inspector buttons and
library chips retire in its favor.

And clicking a library card mid-game no longer summons the App-level
inspector beneath the help modal, greyed out and useless — the
library enlarges its card in its own overlay, centered above the
booklet, seal included.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 15:34:43 -04:00

338 lines
12 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";
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>
</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}
{: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;
}
.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>