import { describe, expect, it } from "vitest"; import { applyCommand, createGame, type GameState, type PlayerId, } from "../src/game"; import { cellKey, edgeKey } from "../src/board"; 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); } }); }); 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("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.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); }); });