Files
wizwar6e/packages/engine/test/automaton.test.ts
T
Eric WagonerandClaude Fable 5 e30f3f0903 The automaton guards gold only within sight (X2XN stall)
The guard-the-gold rung cast SAFE/GLUE at a floor treasure with no
line-of-sight check; the engine refused the cast and the fallback
burned the bot's whole turn.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 21:21:00 -04:00

604 lines
26 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
applyCommand,
createGame,
type GameState,
type PlayerId,
} from "../src/game";
import { cellKey, edgeKey } from "../src/board";
import { sightedCellsFor, 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);
}
});
});
function underAttackShared(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;
}
describe("the clockwork does not waste counters on pointless targets", () => {
it("passes on drop-object rather than absorbing nothing", () => {
const state = underAttackShared("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 = underAttackShared("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("the clockwork guards gold only within sight", () => {
// Game X2XN: the bot blind-cast SAFE at a treasure it could not see, the
// engine refused it, and the fallback burned the whole turn.
function goldOnTheFloor() {
let { state } = createGame({ playerIds: ["bot", "foe"], seed: 7, sets: ["basic", "expansion1"], deckRev: 36 });
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")!;
const foe = state.players.find((p) => p.id === "foe")!;
foe.position = { ...bot.position };
bot.hand = [{ instanceId: "safe#T", cardId: "safe" }];
const gold = state.treasures.find((t) => t.owner === "bot")!;
gold.carriedBy = null;
return { state, bot, gold };
}
it("never blind-casts SAFE at a treasure out of sight", () => {
const { state, bot, gold } = goldOnTheFloor();
const sighted = sightedCellsFor(viewFor(state, "bot"));
const hidden = Object.keys(state.board.cells).find(
(k) => !sighted.has(k) && !state.squareContents[k],
)!;
const [x, y] = hidden.split(",").map(Number);
gold.position = { x: x!, y: y! };
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
// Whatever the brain picks, the engine must accept it — a refusal
// forfeits the bot's turn.
const chosen = cmd ?? automatonFallback(viewFor(state, "bot"), "archmage");
expect(applyCommand(state, "bot", chosen).ok).toBe(true);
});
it("still locks up gold it can see", () => {
const { state, bot, gold } = goldOnTheFloor();
const sighted = sightedCellsFor(viewFor(state, "bot"));
const seen = [...sighted].find(
(k) => k !== cellKey(bot.position) && !state.squareContents[k],
)!;
const [x, y] = seen.split(",").map(Number);
gold.position = { x: x!, y: y! };
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
expect(cmd).toEqual({
type: "cast", instanceId: "safe#T",
target: { kind: "cell", cell: gold.position },
});
expect(applyCommand(state, "bot", cmd!).ok).toBe(true);
});
});
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: `rotate-sector#${i}`, cardId: "rotate-sector" }
));
// 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");
});
});
describe("the clockwork honors absorb's fine print", () => {
it("answers NO SPELL with blunt, never absorb — durations soak no points", () => {
const state = underAttackShared("no-spell", [
{ instanceId: "absorb#T", cardId: "absorb" },
{ instanceId: "blunt#T", cardId: "blunt" },
]);
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
expect(cmd).toEqual({ type: "counteract", instanceId: "blunt#T" });
});
});
describe("the clockwork wields destroy wall", () => {
it("blasts its way out when no road leads to the gold", () => {
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"], deckRev: 24 });
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")!;
// Brick the bot into a one-square cell far from everything.
bot.position = { x: 4, y: 4 };
for (const side of ["N", "S", "E", "W"] as const) {
state.edgeOverrides[edgeKey(bot.position, side)] = "wall";
}
state.players.find((p) => p.id === "other")!.position = { x: 0, y: 0 };
bot.hand[0] = { instanceId: "destroy-wall#T", cardId: "destroy-wall" };
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
expect(cmd).toMatchObject({ type: "cast", instanceId: "destroy-wall#T" });
// And the engine accepts the blast it chose.
const r = applyCommand(state, "bot", cmd!);
expect(r.ok).toBe(true);
});
it("values destroy wall above the chaff when forced to discard", () => {
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"], deckRev: 24 });
const bot = state.players.find((p) => p.id === "bot")!;
bot.hand = [
{ instanceId: "destroy-wall#T", cardId: "destroy-wall" },
{ instanceId: "trader#T", cardId: "trader" },
{ instanceId: "strength#T", cardId: "strength" },
{ instanceId: "adrenaline#T", cardId: "adrenaline" },
{ instanceId: "full-shield#T", cardId: "full-shield" },
{ instanceId: "fireball#T", cardId: "fireball" },
{ instanceId: "number-3#T", cardId: "number-3" },
{ instanceId: "troll#T", cardId: "troll" },
];
state.pendingDiscard = "bot";
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
expect(cmd?.type).toBe("discard");
if (cmd?.type === "discard") {
expect(cmd.instanceIds).not.toContain("destroy-wall#T");
}
});
});
describe("the clockwork wields pass through wall", () => {
it("banks a crossing when bricked in, then steps through the wall", () => {
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"], deckRev: 24 });
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")!;
bot.position = { x: 4, y: 4 };
for (const side of ["N", "S", "E", "W"] as const) {
state.edgeOverrides[edgeKey(bot.position, side)] = "wall";
}
state.players.find((p) => p.id === "other")!.position = { x: 0, y: 0 };
bot.hand[0] = { instanceId: "pass-through-wall#T", cardId: "pass-through-wall" };
const cast = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
expect(cast).toMatchObject({ type: "cast", instanceId: "pass-through-wall#T" });
let r = applyCommand(state, "bot", cast!);
if (!r.ok) throw new Error(r.error);
state = r.state;
expect(state.players.find((p) => p.id === "bot")!.passWallCharges).toBe(1);
// The charge is spent on a step through the bricks.
const step = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
expect(step?.type).toBe("move");
r = applyCommand(state, "bot", step!);
if (!r.ok) throw new Error(r.error);
const after = r.state.players.find((p) => p.id === "bot")!;
expect(cellKey(after.position)).not.toBe(cellKey({ x: 4, y: 4 }));
expect(after.passWallCharges).toBe(0);
});
});
describe("the clockwork denies the road", () => {
it("walls a thief's corridor when its treasure is being carried home", () => {
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"], deckRev: 24 });
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")!;
const thief = state.players.find((p) => p.id === "other")!;
// The thief carries the bot's treasure down a one-lane tube to its home.
const t = state.treasures.find((t) => t.owner === "bot")!;
t.position = null;
t.carriedBy = "other";
thief.carriedTreasureId = t.id;
thief.position = { x: 4, y: 2 };
thief.home = { x: 4, y: 6 };
for (let y = 2; y <= 6; y++) {
state.edgeOverrides[edgeKey({ x: 4, y }, "E")] = "wall";
state.edgeOverrides[edgeKey({ x: 4, y }, "W")] = "wall";
}
state.edgeOverrides[edgeKey({ x: 4, y: 6 }, "S")] = "wall";
for (let y = 2; y <= 5; y++) {
state.edgeOverrides[edgeKey({ x: 4, y }, "S")] = "open";
}
bot.position = { x: 4, y: 4 };
bot.hand = [{ instanceId: "create-wall#T", cardId: "create-wall" }];
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
expect(cmd).toMatchObject({ type: "cast", instanceId: "create-wall#T" });
const r = applyCommand(state, "bot", cmd!);
if (!r.ok) throw new Error(r.error);
// The blockade must actually sever the thief's road home.
const target = (cmd as { target: { cell: { x: number; y: number }; side: string } }).target;
expect(["N", "S"]).toContain(target.side);
expect(target.cell.x).toBe(4);
});
});
describe("the widened spellbook", () => {
it("dispels a conjured wall standing between it and the only road", () => {
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"], deckRev: 24 });
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")!;
bot.position = { x: 4, y: 4 };
for (const side of ["N", "S", "E", "W"] as const) {
const k = edgeKey(bot.position, side);
state.edgeOverrides[k] = "wall";
state.createdEdges[k] = true;
}
state.players.find((p) => p.id === "other")!.position = { x: 0, y: 0 };
bot.hand = [{ instanceId: "dispel-creation#T", cardId: "dispel-creation" }];
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
expect(cmd).toMatchObject({ type: "cast", instanceId: "dispel-creation#T" });
const r = applyCommand(state, "bot", cmd!);
if (!r.ok) throw new Error(r.error);
});
it("offers a buddy pact to the hound at its heels", () => {
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"], deckRev: 24 });
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")!;
const hound = state.players.find((p) => p.id === "other")!;
hound.position = { ...bot.position };
bot.hand = [{ instanceId: "buddy#T", cardId: "buddy" }];
const cmd = automatonCommand(viewFor(state, "bot"), "worrier", "archmage");
expect(cmd).toEqual({
type: "cast", instanceId: "buddy#T",
target: { kind: "player", playerId: "other" },
});
const r = applyCommand(state, "bot", cmd!);
if (!r.ok) throw new Error(r.error);
});
});
describe("round two of the owner's tactics", () => {
it("exiles a thief carrying its gold to the far end of nowhere", () => {
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"], deckRev: 24 });
// burn round 1 and reach the bot's turn
for (let guard = 0; guard < 10 && !(actingSeat(state) === "bot" && state.turn.round > 1); guard++) {
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")!;
const thief = state.players.find((p) => p.id === "other")!;
const t = state.treasures.find((t) => t.owner === "bot")!;
t.position = null;
t.carriedBy = "other";
thief.carriedTreasureId = t.id;
// The thief is a step from delivering; the bot watches from beside them.
thief.position = { x: thief.home.x, y: thief.home.y === 0 ? 1 : thief.home.y - 1 };
bot.position = { ...thief.position };
bot.hand = [{ instanceId: "teleport-opponent#T", cardId: "teleport-opponent" }];
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
expect(cmd).toMatchObject({ type: "cast", instanceId: "teleport-opponent#T" });
const dest = (cmd as { params: { cell: { x: number; y: number } } }).params.cell;
// The chosen square is a long march from the thief's own home.
const d = Math.abs(dest.x - thief.home.x) + Math.abs(dest.y - thief.home.y);
expect(d).toBeGreaterThanOrEqual(5);
const r = applyCommand(state, "bot", cmd!);
if (!r.ok) throw new Error(r.error);
});
it("casts adrenaline when two blows finish what one cannot", () => {
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"], deckRev: 24 });
for (let guard = 0; guard < 10 && !(actingSeat(state) === "bot" && state.turn.round > 1); guard++) {
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")!;
const prey = state.players.find((p) => p.id === "other")!;
prey.position = { ...bot.position };
prey.life = 7; // fireball (5) alone cannot; fireball + dagger (3) can
bot.hand = [
{ instanceId: "adrenaline#T", cardId: "adrenaline" },
{ instanceId: "fireball#T", cardId: "fireball" },
{ instanceId: "dagger#T", cardId: "dagger" },
{ instanceId: "number-2#T", cardId: "number-2" },
];
const cmd = automatonCommand(viewFor(state, "bot"), "berserker", "archmage");
expect(cmd).toMatchObject({ type: "cast", instanceId: "adrenaline#T" });
const r = applyCommand(state, "bot", cmd!);
if (!r.ok) throw new Error(r.error);
});
});
describe("the fireball-then-buddy lockout", () => {
it("burns the target, then signs the pact so they cannot hit back", () => {
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"], deckRev: 24 });
for (let guard = 0; guard < 10 && !(actingSeat(state) === "bot" && state.turn.round > 1); guard++) {
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")!;
const prey = state.players.find((p) => p.id === "other")!;
prey.position = { ...bot.position };
bot.life = 8; // bleeding: the clockwork wants out of this fight
bot.hand = [
{ instanceId: "fireball#T", cardId: "fireball" },
{ instanceId: "buddy#T", cardId: "buddy" },
];
// First: the blow.
let cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
expect(cmd).toMatchObject({ type: "cast", instanceId: "fireball#T" });
let r = applyCommand(state, "bot", cmd!);
if (!r.ok) throw new Error(r.error);
state = r.state;
r = applyCommand(state, "other", { type: "pass" });
if (!r.ok) throw new Error(r.error);
state = r.state;
// Then: the pact.
cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
expect(cmd).toEqual({
type: "cast", instanceId: "buddy#T",
target: { kind: "player", playerId: "other" },
});
r = applyCommand(state, "bot", cmd!);
if (!r.ok) throw new Error(r.error);
state = r.state;
// The pact holds: the victim cannot strike their tormentor.
expect(state.sustained.some(
(s) => s.cardId === "buddy" && s.casterId === "bot" && s.targetId === "other",
)).toBe(true);
});
});
describe("a pact once signed is honored", () => {
it("will not attack the wizard it just buddied", () => {
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"], deckRev: 25 });
for (let guard = 0; guard < 10 && !(actingSeat(state) === "bot" && state.turn.round > 1); guard++) {
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")!;
const prey = state.players.find((p) => p.id === "other")!;
prey.position = { ...bot.position };
// The pact already stands; a fireball waits in hand as temptation.
state.sustained.push({
id: "fx-test", cardId: "buddy", casterId: "bot", targetId: "other",
turnsLeft: 1000, edge: undefined,
} as never);
bot.hand = [{ instanceId: "fireball#T", cardId: "fireball" }];
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
// Anything but an attack on the pacted wizard: no cast at them, no punch.
expect(cmd?.type === "punch").toBe(false);
if (cmd?.type === "cast") {
expect((cmd as { target?: { playerId?: string } }).target?.playerId).not.toBe("other");
}
});
});
describe("no number is wasted on a turn that ends at a grab", () => {
it("gold two free steps away: the berserker charges without spending its 5", () => {
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"], deckRev: 29 });
for (let guard = 0; guard < 10 && !(actingSeat(state) === "bot" && state.turn.round > 1); guard++) {
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")!;
const prey = state.players.find((p) => p.id === "other")!;
// Enemy far across the maze; their treasure lies one step from the bot.
prey.position = { x: 0, y: 0 };
bot.position = { x: 5, y: 5 };
const t = state.treasures.find((t) => t.owner === "other")!;
t.position = { x: 5, y: 6 };
t.carriedBy = null;
state.edgeOverrides[edgeKey({ x: 5, y: 5 }, "S")] = "open";
bot.hand = [
{ instanceId: "number-5#T", cardId: "number-5" },
{ instanceId: "number-2#T", cardId: "number-2" },
];
const cmd = automatonCommand(viewFor(state, "bot"), "berserker", "archmage");
expect(cmd?.type).not.toBe("playNumberForMovement");
});
});
describe("the clockwork flees the dread", () => {
it("caught in FEAR's bubble, it spends its legs moving away", () => {
let { state } = createGame({ playerIds: ["bot", "grim"], seed: 42, sets: ["basic"], deckRev: 32 });
for (let guard = 0; guard < 10 && !(actingSeat(state) === "bot" && state.turn.round > 1); guard++) {
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")!;
const grim = state.players.find((p) => p.id === "grim")!;
grim.position = { x: 2, y: 5 };
bot.position = { x: 2, y: 7 }; // two spaces inside the dread
state.sustained.push({
id: "fx-fear", cardId: "fear", casterId: "grim", targetId: "grim",
remainingTurns: 5, data: {},
} as never);
const cmd = automatonCommand(viewFor(state, "bot"), "berserker", "archmage");
expect(cmd?.type).toBe("move");
if (cmd?.type === "move") {
const r = applyCommand(state, "bot", cmd);
if (!r.ok) throw new Error(r.error);
const after = r.state.players.find((p) => p.id === "bot")!;
const d = Math.abs(after.position.x - grim.position.x) + Math.abs(after.position.y - grim.position.y);
expect(d).toBeGreaterThan(2);
}
});
});