The telepath audit becomes a guard test, and the audits get a skill
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
813f92d419
commit
a4f572a164
@@ -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/<CODE>.jsonl <scratchpad>/
|
||||||
|
|
||||||
|
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).
|
||||||
+2
-1
@@ -4,5 +4,6 @@ dist/
|
|||||||
.env
|
.env
|
||||||
.DS_Store
|
.DS_Store
|
||||||
data/
|
data/
|
||||||
.claude/
|
.claude/*
|
||||||
|
!.claude/skills/
|
||||||
.playwright-mcp/
|
.playwright-mcp/
|
||||||
|
|||||||
@@ -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<string, string> = {
|
||||||
|
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<string>();
|
||||||
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user