// 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"/.test(entry) && !cellCards.has(id)) missing.push(`${id} (cell)`); if (/target \|\| cmd\.target\.kind !== "edge"/.test(entry) && !edgeCards.has(id)) missing.push(`${id} (edge)`); } expect(missing, `casts the board cannot aim: ${missing.join(", ")}`).toEqual([]); }); });