Three blind reviews over the first-person arc, every finding verified against the source. History-narrating comments made timeless or cut; the stacked BUDDY comment collapsed to one voice. Dead code out: the orphaned FACE copy, the DIR_ANGLE duplicate, the dead loop-counter poke, the impossible-state sentinel. The never-produced "warp" hit kind resolved the right way — warp mouths now hang a translucent veil of the painted warp texture, so art that never rendered finally does. Types tightened (SlabStrike named once, ShareData rides CatchUpStep, botTier loses its casts), the gallery reads MATERIALS instead of a hand-copied list, the chronicle resets through one helper, a superseded share mint rejects instead of stranding, and the conjured safe gains the growth key its siblings had. Tests lose a triple assignment, a tautology, and two self-swallowing regex alternatives. The sprite spec — which still told the artist to paint for additive compositing the renderer no longer uses — now describes the renderer that exists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
74 lines
3.7 KiB
TypeScript
74 lines
3.7 KiB
TypeScript
// 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"/.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([]);
|
|
});
|
|
});
|