Files
wizwar6e/packages/engine/test/automaton.test.ts
T
Eric WagonerandClaude Fable 5 d2b40ec6f8 Credibility pass: the seams get sanded
Scoped to everything since the last pass (3308850). Three blind
reviews, every finding verified before touching anything.

Confirmed and fixed: two identical comment-splitting insertions left
doc comments orphaned from their fields (net and local alike); a
51-line CSS fossil of the pre-extraction inline effects survived in
Board.svelte; the rev-13 miss-roll test asserted tautologies while
its comment claimed a check the code never made — it now proves the
die was consumed, and the skeleton is no longer returned as trollId;
the sprite registry's `as never` silently disabled the completeness
its annotation advertised (now a mapped type, one cast at the
dispatch seam); a dead ternary guarded a union that doesn't exist;
Bolt carried a duplicate .fork rule from a color iteration; fxTtl
contradicted three sprites' real animation lengths; the permanence
sentinel was reinvented as a magic 9000 (the engine now exports
isPermanentDuration); CELL was declared thrice (fx.ts now imports
it); App and Replay ran two divergent fx schedulers (one scheduleFx
now, cancellable — stale flourishes can no longer fire after leaving
a game); TokenArt retried missing files forever; the anti-anti
escape guards merge with the gate asymmetry explained; wall-of-fire's
rev-12 carve-out is marked; overLimit ignored a displayed BRAINSTONE
(bots over-discarded by two); botRemark's header mis-stated its own
branches; deliverGold fired on any drop, not a home-base delivery;
escape and win banter never fired from the steps that carry them.

Rejected: "as a human would" (house voice); FxGallery's dev-harness
framing (trimmed one plea, kept the facts).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 12:12:03 -04:00

206 lines
8.0 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
applyCommand,
createGame,
type GameState,
type PlayerId,
} from "../src/game";
import { viewFor } from "../src/view";
import { automatonCommand, automatonFallback, type AutomatonStyle, type AutomatonTier } from "../src/automaton";
/** Whose input does the maze want right now? */
function actingSeat(state: GameState): PlayerId {
return (
state.stack?.waitingOn ??
state.pendingDiscard ??
state.chaosPending?.queue[0] ??
state.outOfTurnWindow?.playerId ??
state.players[state.turn.activeIndex]!.id
);
}
/** Drive a full bot-vs-bot game; returns the final state and command count. */
function playOut(
seed: number, players: number, expansion = true,
styles: AutomatonStyle[] = [], tiers: AutomatonTier[] = [],
) {
const ids = Array.from({ length: players }, (_, i) => `bot${i + 1}`);
const styleOf = new Map(ids.map((id, i) => [id, styles[i] ?? "hunter"]));
const tierOf = new Map(ids.map((id, i) => [id, tiers[i] ?? "archmage"]));
let { state } = createGame({
playerIds: ids,
seed,
sets: expansion ? ["basic", "expansion1"] : ["basic"],
deckRev: 8,
});
let commands = 0;
let stuck = 0;
const CAP = 4000;
while (state.phase === "playing" && commands < CAP) {
const seat = actingSeat(state);
const view = viewFor(state, seat);
const tier = tierOf.get(seat);
const chosen = automatonCommand(view, styleOf.get(seat), tier);
const cmd = chosen ?? automatonFallback(view, tier);
let r = applyCommand(state, seat, cmd);
if (!r.ok) {
// Retry with the fallback only if the fallback was not what just failed.
const fb = automatonFallback(view, tier);
if (chosen) r = applyCommand(state, seat, fb);
if (!r.ok) {
stuck++;
if (stuck > 3) {
throw new Error(
`automaton stuck at seat ${seat} after ${commands} commands: ` +
`${JSON.stringify(cmd)} -> ${JSON.stringify(fb)} both refused (${r.error})`,
);
}
// Last-ditch: burn the turn structure forward.
r = applyCommand(state, seat, { type: "endTurn", draw: 0 });
if (!r.ok) r = applyCommand(state, seat, { type: "pass" });
if (!r.ok) throw new Error(`unrecoverable at ${seat}: ${r.error}`);
}
} else {
stuck = 0;
}
state = r.state;
commands++;
}
return { state, commands };
}
describe("automaton vs automaton", () => {
it("two clockwork wizards fight a game to its end", () => {
const { state } = playOut(11, 2);
expect(state.phase).toBe("finished");
expect(state.winner).not.toBeNull();
});
it("holds up across many seeds without wedging", () => {
let finished = 0;
for (const seed of [1, 2, 3, 5, 8, 13, 21, 34]) {
const { state } = playOut(seed, 2);
if (state.phase === "finished") finished++;
}
// Cautious clockwork can stall a maze; most games must still conclude.
expect(finished).toBeGreaterThanOrEqual(6);
});
it("a full table of four automatons concludes", () => {
const { state } = playOut(7, 4);
expect(state.phase).toBe("finished");
});
it("the apprentice handicap bites where cards decide: combat mirrors", () => {
// Deterministic across these seeds: same brains, same dice.
let arch = 0, appr = 0;
for (const seed of [1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233]) {
const { state } = playOut(seed, 2, true,
["berserker", "berserker"], ["archmage", "apprentice"]);
if (state.winner === "bot1") arch++;
if (state.winner === "bot2") appr++;
}
expect(arch).toBeGreaterThan(appr);
});
it("every temperament finishes its wars", () => {
for (const styles of [
["berserker", "hunter"], ["worrier", "hunter"], ["berserker", "worrier"],
] as AutomatonStyle[][]) {
let finished = 0;
for (const seed of [3, 17, 29]) {
const { state } = playOut(seed, 2, true, styles);
if (state.phase === "finished") finished++;
}
expect(finished).toBeGreaterThanOrEqual(2);
}
});
});
describe("the clockwork does not waste counters on pointless targets", () => {
function underAttack(attackId: string, defenderHand: { instanceId: string; cardId: string }[], rig?: (s: GameState, defender: string) => void) {
let { state } = createGame({ playerIds: ["human", "bot"], seed: 42, sets: ["basic", "expansion1"], deckRev: 13 });
// burn round 1
for (let i = 0; i < 2; i++) {
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(`setup: ${r.error}`);
state = r.state;
}
while (actingSeat(state) !== "human") {
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(`setup: ${r.error}`);
state = r.state;
}
const human = state.players.find((p) => p.id === "human")!;
const bot = state.players.find((p) => p.id === "bot")!;
bot.position = { ...human.position };
defenderHand.forEach((c, i) => { bot.hand[i] = c; });
human.hand[0] = { instanceId: `${attackId}#A`, cardId: attackId };
rig?.(state, "bot");
const r = applyCommand(state, "human", {
type: "cast", instanceId: `${attackId}#A`, target: { kind: "player", playerId: "bot" },
...(attackId === "drop-object" ? { params: { cardId: "dagger" } } : {}),
});
if (!r.ok) throw new Error(`setup: ${r.error}`);
return r.state;
}
it("passes on drop-object rather than absorbing nothing", () => {
const state = underAttack("drop-object", [
{ instanceId: "absorb#T", cardId: "absorb" },
{ instanceId: "blunt#T", cardId: "blunt" },
{ instanceId: "dagger#T", cardId: "dagger" },
]);
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
expect(cmd).toEqual({ type: "pass" });
});
it("shields a drop-object aimed at its carried treasure", () => {
const state = underAttack("drop-object", [
{ instanceId: "full-shield#T", cardId: "full-shield" },
{ instanceId: "dagger#T", cardId: "dagger" },
], (s, defender) => {
const d = s.players.find((p) => p.id === defender)!;
const t = s.treasures.find((t) => t.owner !== defender)!;
t.position = null; t.carriedBy = defender; d.carriedTreasureId = t.id;
});
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
expect(cmd).toEqual({ type: "counteract", instanceId: "full-shield#T" });
});
});
describe("a clogged hand gets shed, not hoarded", () => {
it("the bot discards dead weight so the end-of-turn draw has room", () => {
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"], deckRev: 13 });
while (actingSeat(state) !== "bot") {
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(r.error);
state = r.state;
}
const bot = state.players.find((p) => p.id === "bot")!;
// Seven situational neutrals the brain has no play for: a dead hand.
bot.hand = Array.from({ length: 7 }, (_, i) => (
{ instanceId: `illusion-wall#${i}`, cardId: "illusion-wall" }
));
// Walk the bot's turn until it wants to end: it must shed before drawing.
for (let guard = 0; guard < 30; guard++) {
const view = viewFor(state, "bot");
const cmd = automatonCommand(view, "hunter", "archmage")!;
if (cmd.type === "discard") {
expect(cmd.instanceIds.length).toBe(2);
const r = applyCommand(state, "bot", cmd);
if (!r.ok) throw new Error(r.error);
state = r.state;
const next = automatonCommand(viewFor(state, "bot"), "hunter", "archmage")!;
expect(next).toEqual({ type: "endTurn", draw: 2 });
return;
}
if (cmd.type === "endTurn") throw new Error("ended the turn without shedding a dead hand");
const r = applyCommand(state, "bot", cmd);
if (!r.ok) throw new Error(`${cmd.type}: ${r.error}`);
state = r.state;
}
throw new Error("never reached the end of the turn");
});
});