From a4f572a1646f0641155f37e08f029bdaa226d36a Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Sun, 23 Aug 2026 12:01:22 -0400 Subject: [PATCH] The telepath audit becomes a guard test, and the audits get a skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ui-coverage.test.ts fails the suite the moment an engine event has no client handling (allowlist entries require a written reason) or a cell/edge-targeting card is missing from the board's aim sets — the one-at-a-time bug reports become a red test instead. The wizwar-audits skill documents the three house audits (UI coverage, ledger inspection by room code, the determinism gate); .claude/skills/ joins the repo so they travel with it. Co-Authored-By: Claude Fable 5 --- .claude/skills/wizwar-audits/SKILL.md | 59 +++++++++++++++++++ .gitignore | 3 +- packages/engine/test/ui-coverage.test.ts | 73 ++++++++++++++++++++++++ 3 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 .claude/skills/wizwar-audits/SKILL.md create mode 100644 packages/engine/test/ui-coverage.test.ts diff --git a/.claude/skills/wizwar-audits/SKILL.md b/.claude/skills/wizwar-audits/SKILL.md new file mode 100644 index 0000000..60a999c --- /dev/null +++ b/.claude/skills/wizwar-audits/SKILL.md @@ -0,0 +1,59 @@ +--- +name: wizwar-audits +description: Run the Wiz-War health audits — UI coverage of engine events/cards (the "telepath audit"), live-game ledger inspection by room code, and the pre-deploy determinism gate. Use when asked to audit, when adding cards or events, or when a player reports "the engine did X but I never saw it". +--- + +# Wiz-War audits + +Three recurring audits keep the maze honest. The first is self-enforcing; +the other two are on-demand. + +## 1. UI coverage — "the telepath audit" (automated) + +`packages/engine/test/ui-coverage.test.ts` runs with every `npm test` and +fails when: + +- a GameEvent type has no client handling (no `humanize` case in + `net.svelte.ts`, no fx in `fx.ts`, no modal check in `App.svelte`) and is + not in the test's `SILENT_BY_DESIGN` allowlist — each allowlist entry + needs a reason for why the player sees the information another way; +- a card resolver demanding a cell/edge target is missing from App's + `CELL_CARDS` / `EDGE_CARDS` click-targeting sets (an unaimable cast). + +When adding an **event**: give it a `humanize` line at minimum. Private +info (`visibleTo` events carrying cards) deserves the `cardReveal` modal +in App.svelte — see `handRevealedPrivate` / `cardsStolenPrivate` / +`handTakenPrivate` for the pattern. When adding a **card** with a cell or +edge target, add it to the App targeting set; with `params`, wire the +named-card picker (`NAMED_CARDS` + suggestions) or a bespoke control. + +The one axis the test cannot judge: whether a `params`-taking card's +input UI actually offers sensible choices. Check that by hand when adding +one (thief → target's steallables, deja-vu → discard contents, etc.). + +## 2. Live-game audit by room code + +Fetch and replay production ledgers (details in auto-memory +`wizwar-fetch-game-files`): + + scp root@104.236.96.198:/var/lib/wizwar/rooms/.jsonl / + +Replay with `createGame({playerIds, seed, sets, colors, deckRev})` + +`applyCommand` per command line (see `deploy/replay-verify.mjs`). Stop at +any seq to inspect full state. To ask why a bot did something, rebuild +the state at its turn and call `automatonCommand(viewFor(state, id), +style, tier)` — and if its choice differs from the ledger, the engine +refused it and the fallback burned the turn (the X2XN pattern). + +For "which games are open/stalled" sweeps: fetch all `*.jsonl`, replay +each, and report phase / round / humans vs bots / last command's `at`. + +## 3. Determinism gate (before deploying engine changes) + + deploy/verify-ledgers.sh 104.236.96.198 + +Strict-replays every production ledger against the local engine; one +refused command fails. A room whose ledger no longer replays becomes +unreachable after restart. Rules changes while games are live need a +`deckRev` bump plus an engine gate (the convention survives the 2026-08 +reset to rev 1). diff --git a/.gitignore b/.gitignore index 8d2e958..c6ab4ba 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,6 @@ dist/ .env .DS_Store data/ -.claude/ +.claude/* +!.claude/skills/ .playwright-mcp/ diff --git a/packages/engine/test/ui-coverage.test.ts b/packages/engine/test/ui-coverage.test.ts new file mode 100644 index 0000000..219f7d2 --- /dev/null +++ b/packages/engine/test/ui-coverage.test.ts @@ -0,0 +1,73 @@ +// The telepath guard: every event the engine emits must reach the player — +// a log line, an effect, or a modal — and every targeted cast must be +// aimable on the board. A new event or card that slips past the client +// fails here, instead of surfacing one puzzled bug report at a time. +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +const ENGINE = join(__dirname, "..", "src"); +const WEB = join(__dirname, "..", "..", "web", "src"); +const game = readFileSync(join(ENGINE, "game.ts"), "utf8"); +const app = readFileSync(join(WEB, "App.svelte"), "utf8"); +const clientSources = ["net.svelte.ts", "fx.ts", "local.svelte.ts", "Replay.svelte", "hints.ts"] + .map((f) => readFileSync(join(WEB, f), "utf8")) + .concat(app) + .join("\n"); + +/** Events whose information reaches the player another way — each with the + * reason. Add here ONLY with a reason; "I'll wire it later" is not one. */ +const SILENT_BY_DESIGN: Record = { + cardsDealt: "the opening deal: the hand tray is the reveal", + cardsDealtPrivate: "same — your opening hand appears in the tray", + boobytrapPlacedPrivate: "the board marks the caster's real token from the view", +}; + +function eventTypes(): string[] { + const start = game.indexOf("export type GameEvent ="); + const block = game.slice(start, game.indexOf("\nexport ", start + 10)); + return [...new Set([...block.matchAll(/\| \{ type: "([a-zA-Z]+)"/g)].map((m) => m[1]!))]; +} + +describe("every engine event reaches the player", () => { + it("is humanized, animated, handled in a modal, or silent by design", () => { + const handled = new Set(); + for (const m of clientSources.matchAll(/case "([a-zA-Z]+)"/g)) handled.add(m[1]!); + for (const m of clientSources.matchAll(/type === "([a-zA-Z]+)"/g)) handled.add(m[1]!); + const orphans = eventTypes().filter((e) => !handled.has(e) && !(e in SILENT_BY_DESIGN)); + expect(orphans, `events no client code touches: ${orphans.join(", ")}`).toEqual([]); + }); + + it("keeps the silent-by-design list honest (no stale entries)", () => { + const known = new Set(eventTypes()); + const stale = Object.keys(SILENT_BY_DESIGN).filter((e) => !known.has(e)); + expect(stale, `allowlisted events the engine no longer emits: ${stale.join(", ")}`).toEqual([]); + }); +}); + +describe("every targeted cast is aimable on the board", () => { + /** A card's resolver demanding a cell/edge target must appear in App's + * click-targeting sets, or selecting the card leaves it uncastable. */ + it("cell- and edge-demanding resolvers appear in CELL_CARDS / EDGE_CARDS", () => { + const start = game.indexOf("const CARD_EFFECTS"); + const end = game.indexOf("\n};", start); // the table's own closing brace + const entries = game.slice(start, end).split(/\n (?="?[a-z][a-z0-9-]*"?: \{)/); + const appSet = (name: string) => + new Set([...(app.match(new RegExp(`${name} = new Set\\(\\[([^\\]]*)\\]`))?.[1] ?? "") + .matchAll(/"([a-z-]+)"/g)].map((m) => m[1]!)); + const cellCards = appSet("CELL_CARDS"); + const edgeCards = appSet("EDGE_CARDS"); + const missing: string[] = []; + for (const entry of entries) { + const id = entry.match(/^"?([a-z][a-z0-9-]*)"?: \{/)?.[1]; + if (!id) continue; + // Only demands stated as refusals bind: `target.kind !== "cell"` etc. + // (an optional `target?.kind === ...` branch is not a requirement). + if (/target \|\| cmd\.target\.kind !== "cell"|!cmd\.target \|\| cmd\.target\.kind !== "cell"/.test(entry) && + !cellCards.has(id)) missing.push(`${id} (cell)`); + if (/target \|\| cmd\.target\.kind !== "edge"|!cmd\.target \|\| cmd\.target\.kind !== "edge"/.test(entry) && + !edgeCards.has(id)) missing.push(`${id} (edge)`); + } + expect(missing, `casts the board cannot aim: ${missing.join(", ")}`).toEqual([]); + }); +});