'All LOCK-type cards will work on it' was never wired: openSafes was reset every turn and filled by nothing. A PICK LOCK or MASTER KEY aimed at a safe's square (underfoot or beside) now opens it until turn's end, with its own chronicle line; the dead never-emitted safeOpened variant and its null humanize go. The automaton — H4EN's Automaton II spent six turns grabbing at a locked lid saying 'I meant to do that' — now cracks the box first (key or DISPEL CREATION, which it held the whole time) and drops unopenable safes from its goals rather than marching to one forever. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
1101 lines
50 KiB
TypeScript
1101 lines
50 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
applyCommand,
|
|
createGame,
|
|
sustainedOn,
|
|
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";
|
|
import { pushSustained } from "./helpers";
|
|
|
|
/** Whose input does the maze want right now? */
|
|
function actingSeat(state: GameState): PlayerId {
|
|
return (
|
|
state.wardPending?.ownerId ??
|
|
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"],
|
|
});
|
|
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 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"] });
|
|
// 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 = 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("the clockwork guards gold only within sight", () => {
|
|
// A refused cast forfeits the bot's whole turn (the fallback is endTurn),
|
|
// so SAFE must only ever be offered where the engine's sight rule allows it.
|
|
function goldOnTheFloor() {
|
|
let { state } = createGame({ playerIds: ["bot", "foe"], seed: 7, sets: ["basic", "expansion1"] });
|
|
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("the clockwork honors IDIOT's march", () => {
|
|
// The engine never steers a cursed wizard's feet; the duty is the brain's.
|
|
function cursedBot() {
|
|
let { state } = createGame({ playerIds: ["bot", "foe"], seed: 7, sets: ["basic", "expansion1"] });
|
|
// Past round 1 (no combat) and around to the bot's turn.
|
|
for (let i = 0; i < 2; i++) {
|
|
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
|
if (!r.ok) throw new Error(r.error);
|
|
state = r.state;
|
|
}
|
|
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;
|
|
}
|
|
pushSustained(state, {
|
|
id: "fx-idiot", cardId: "idiot", casterId: "foe", targetId: "bot",
|
|
remainingTurns: 9999, data: {},
|
|
});
|
|
return state;
|
|
}
|
|
|
|
it("marches to its own gold until the curse lifts", () => {
|
|
let state = cursedBot();
|
|
state.players.find((p) => p.id === "bot")!.hand = [];
|
|
// A walled route can cost more steps than one turn's allowance; the
|
|
// march may span turns (the foe just passes).
|
|
for (let i = 0; i < 30 && sustainedOn(state, "bot", "idiot").length > 0; i++) {
|
|
const seat = actingSeat(state);
|
|
const cmd = seat === "bot"
|
|
? automatonCommand(viewFor(state, "bot"), "hunter", "archmage")
|
|
?? automatonFallback(viewFor(state, "bot"), "archmage")
|
|
: { type: "endTurn", draw: 0 } as const;
|
|
const r = applyCommand(state, seat, cmd);
|
|
if (!r.ok) throw new Error(r.error);
|
|
state = r.state;
|
|
}
|
|
expect(sustainedOn(state, "bot", "idiot").length).toBe(0);
|
|
});
|
|
|
|
it("shakes its gold from a thief's arms with DROP OBJECT", () => {
|
|
const state = cursedBot();
|
|
const bot = state.players.find((p) => p.id === "bot")!;
|
|
const thief = state.players.find((p) => p.id === "foe")!;
|
|
thief.position = { ...bot.position };
|
|
const own = state.treasures.find((t) => t.owner === "bot")!;
|
|
own.carriedBy = "foe";
|
|
own.position = null;
|
|
thief.carriedTreasureId = own.id;
|
|
bot.hand = [{ instanceId: "drop-object#T", cardId: "drop-object" }];
|
|
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
|
expect(cmd).toEqual({
|
|
type: "cast", instanceId: "drop-object#T",
|
|
target: { kind: "player", playerId: "foe" }, params: { cardId: "treasure" },
|
|
});
|
|
expect(applyCommand(state, "bot", cmd!).ok).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("the clockwork wades hazards rather than surrender", () => {
|
|
it("takes the slime road when no clean path to anything exists", () => {
|
|
let { state } = createGame({ playerIds: ["bot", "foe"], seed: 7, sets: ["basic", "expansion1"] });
|
|
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.hand = [];
|
|
// Wall the bot into its square except one side; slime that one exit.
|
|
const sides = ["N", "S", "E", "W"] as const;
|
|
const board = state.board;
|
|
const open = sides.filter((s) => {
|
|
const n = { x: bot.position.x + (s === "E" ? 1 : s === "W" ? -1 : 0),
|
|
y: bot.position.y + (s === "S" ? 1 : s === "N" ? -1 : 0) };
|
|
return board.cells[cellKey(n)] !== undefined;
|
|
});
|
|
const exit = open[0]!;
|
|
for (const s2 of sides) {
|
|
if (s2 !== exit) state.edgeOverrides[edgeKey(bot.position, s2)] = "wall";
|
|
else state.edgeOverrides[edgeKey(bot.position, s2)] = "open";
|
|
}
|
|
const beyond = { x: bot.position.x + (exit === "E" ? 1 : exit === "W" ? -1 : 0),
|
|
y: bot.position.y + (exit === "S" ? 1 : exit === "N" ? -1 : 0) };
|
|
state.squareContents[cellKey(beyond)] = { kind: "slime", damage: 0, createdBy: "foe" };
|
|
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
|
expect(cmd).toEqual({ type: "move", direction: exit });
|
|
expect(applyCommand(state, "bot", cmd!).ok).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("the clockwork respects the bush's shelter", () => {
|
|
function faceOffWithFireball() {
|
|
let { state } = createGame({ playerIds: ["bot", "foe"], seed: 7, sets: ["basic", "expansion1"] });
|
|
for (let i = 0; i < 2; i++) {
|
|
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
|
if (!r.ok) throw new Error(r.error);
|
|
state = r.state;
|
|
}
|
|
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: "fireball#T", cardId: "fireball" }];
|
|
return { state, bot, foe };
|
|
}
|
|
|
|
it("never aims at a wizard sheltered in a thornbush", () => {
|
|
const { state, foe } = faceOffWithFireball();
|
|
state.squareContents[cellKey(foe.position)] = { kind: "thornbush", damage: 0, createdBy: "foe" };
|
|
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage")
|
|
?? automatonFallback(viewFor(state, "bot"), "archmage");
|
|
// Whatever it picks, the engine must accept it — and it must not be
|
|
// the refused fireball that would burn the whole turn.
|
|
expect(cmd).not.toMatchObject({ type: "cast", instanceId: "fireball#T" });
|
|
expect(applyCommand(state, "bot", cmd).ok).toBe(true);
|
|
});
|
|
|
|
it("never attacks out of its own bush", () => {
|
|
const { state, bot, foe } = faceOffWithFireball();
|
|
foe.position = { ...bot.position };
|
|
state.squareContents[cellKey(bot.position)] = { kind: "thornbush", damage: 0, createdBy: "foe" };
|
|
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage")
|
|
?? automatonFallback(viewFor(state, "bot"), "archmage");
|
|
expect(cmd).not.toMatchObject({ type: "cast", instanceId: "fireball#T" });
|
|
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"] });
|
|
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 = underAttack("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"] });
|
|
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"] });
|
|
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"] });
|
|
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"] });
|
|
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"] });
|
|
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"] });
|
|
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("finishing tactics: exile and adrenaline", () => {
|
|
it("exiles a thief carrying its gold to the far end of nowhere", () => {
|
|
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"] });
|
|
// 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"] });
|
|
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"] });
|
|
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"] });
|
|
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.
|
|
pushSustained(state, {
|
|
id: "fx-test", cardId: "buddy", casterId: "bot", targetId: "other",
|
|
remainingTurns: 1000, data: {},
|
|
});
|
|
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"] });
|
|
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"] });
|
|
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
|
|
pushSustained(state, {
|
|
id: "fx-fear", cardId: "fear", casterId: "grim", targetId: "grim",
|
|
remainingTurns: 5, data: {},
|
|
});
|
|
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);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("the thief-chase holds one goal per turn", () => {
|
|
it("a clockwork beside its thief never shuttles on and off their square", () => {
|
|
let { state } = createGame({ playerIds: ["thief", "bot"], seed: 42, sets: ["basic", "expansion1"] });
|
|
// Round 2, thief's turn burned; the bot acts with a full allowance.
|
|
while (state.turn.round < 2 || state.players[state.turn.activeIndex]!.id !== "bot") {
|
|
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
|
if (!r.ok) throw new Error(r.error);
|
|
state = r.state;
|
|
}
|
|
const thief = state.players.find((p) => p.id === "thief")!;
|
|
const bot = state.players.find((p) => p.id === "bot")!;
|
|
// The thief carries the bot's gold and stands one step away.
|
|
const mine = state.treasures.find((t) => t.owner === "bot")!;
|
|
mine.carriedBy = "thief";
|
|
mine.position = null;
|
|
thief.position = { x: 2, y: 4 };
|
|
bot.position = { x: 2, y: 5 };
|
|
state.edgeOverrides[edgeKey({ x: 2, y: 4 }, "S")] = "open";
|
|
// A number card and no attacks: the blow the chase serves cannot land.
|
|
bot.hand = [];
|
|
bot.hand.push({ cardId: "number-2", instanceId: "N2" });
|
|
const visited = [cellKey(bot.position)];
|
|
for (let guard = 0; guard < 40; guard++) {
|
|
const view = viewFor(state, "bot");
|
|
const cmd = automatonCommand(view, "hunter", "archmage") ?? automatonFallback(view, "archmage");
|
|
if (cmd.type === "endTurn") break;
|
|
const r = applyCommand(state, "bot", cmd);
|
|
if (!r.ok) break;
|
|
state = r.state;
|
|
const at = cellKey(state.players.find((p) => p.id === "bot")!.position);
|
|
if (cmd.type === "move" && at !== visited[visited.length - 1]) visited.push(at);
|
|
}
|
|
// No step may return to the square just departed: A-B-A is the shuttle.
|
|
for (let i = 2; i < visited.length; i++) {
|
|
expect(visited[i]).not.toBe(visited[i - 2]);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("the archmage's key, shield, and race discipline", () => {
|
|
function botTurn() {
|
|
const seed = 42;
|
|
let { state } = createGame({ playerIds: ["foe", "bot"], seed, sets: ["basic", "expansion1"] });
|
|
while (state.turn.round < 2 || state.players[state.turn.activeIndex]!.id !== "bot") {
|
|
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
|
if (!r.ok) throw new Error(r.error);
|
|
state = r.state;
|
|
}
|
|
return state;
|
|
}
|
|
|
|
it("a MASTER KEY drawn goes straight on display", () => {
|
|
const state = botTurn();
|
|
const bot = state.players.find((p) => p.id === "bot")!;
|
|
bot.hand.push({ cardId: "master-key", instanceId: "MK" });
|
|
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
|
expect(cmd).toEqual({ type: "cast", instanceId: "MK" });
|
|
});
|
|
|
|
it("a lethal small blow is countered, thrift be damned", () => {
|
|
let { state } = createGame({ playerIds: ["foe", "bot"], seed: 42, sets: ["basic", "expansion1"] });
|
|
while (state.turn.round < 2 || state.players[state.turn.activeIndex]!.id !== "foe") {
|
|
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
|
if (!r.ok) throw new Error(r.error);
|
|
state = r.state;
|
|
}
|
|
const foe = state.players.find((p) => p.id === "foe")!;
|
|
const bot = state.players.find((p) => p.id === "bot")!;
|
|
bot.position = { ...foe.position };
|
|
bot.life = 2;
|
|
bot.hand = [{ cardId: "full-shield", instanceId: "FS" }];
|
|
foe.hand.push({ cardId: "fireball", instanceId: "FB" });
|
|
const r = applyCommand(state, "foe", {
|
|
type: "cast", instanceId: "FB", target: { kind: "player", playerId: "bot" },
|
|
});
|
|
if (!r.ok) throw new Error(r.error);
|
|
// Base fireball, 2 points: below every thrift threshold, but fatal at
|
|
// 2 life — the shield comes out.
|
|
const cmd = automatonCommand(viewFor(r.state, "bot"), "hunter", "archmage");
|
|
expect(cmd).toEqual({ type: "counteract", instanceId: "FS" });
|
|
});
|
|
|
|
it("no number is hoarded against the delivery that wins the game", () => {
|
|
let state = botTurn();
|
|
const bot = state.players.find((p) => p.id === "bot")!;
|
|
const foe = state.players.find((p) => p.id === "foe")!;
|
|
// One of the foe's treasures already rests at the bot's home; the bot
|
|
// carries the second, four squares out with three legs — only the
|
|
// number bridges it home this turn.
|
|
const gold = state.treasures.filter((t) => t.owner === "foe");
|
|
expect(gold.length).toBeGreaterThanOrEqual(2);
|
|
gold[0]!.position = { ...bot.home };
|
|
gold[0]!.carriedBy = null;
|
|
gold[1]!.carriedBy = "bot";
|
|
gold[1]!.position = null;
|
|
bot.carriedTreasureId = gold[1]!.id;
|
|
foe.position = { ...bot.home }; // a foe in sight: the war chest would hoard
|
|
// Legs only: an attack in hand would fire at the foe in sight and
|
|
// open a stack this single-seat loop cannot answer.
|
|
bot.hand = [{ cardId: "number-3", instanceId: "N3" }];
|
|
// Carve a straight, open four-square run to home; the rig owns its
|
|
// geometry rather than praying the seed provides it.
|
|
const home = bot.home;
|
|
const column = [4, 3, 2, 1, 0].map((d) => ({ x: home.x, y: home.y - d }));
|
|
for (const c of column) {
|
|
if (!viewFor(state, "bot").board.cells[cellKey(c)]) {
|
|
throw new Error("setup: the column north of home runs off the board");
|
|
}
|
|
delete state.squareContents[cellKey(c)];
|
|
}
|
|
bot.position = { ...column[0]! };
|
|
for (const c of column.slice(0, 4)) state.edgeOverrides[edgeKey(c, "S")] = "open";
|
|
for (let guard = 0; guard < 12; guard++) {
|
|
const view = viewFor(state, "bot");
|
|
const cmd = automatonCommand(view, "hunter", "archmage") ?? automatonFallback(view, "archmage");
|
|
if (cmd.type === "playNumberForMovement") return; // the chest opened for the win
|
|
if (cmd.type === "endTurn") break;
|
|
const r = applyCommand(state, "bot", cmd);
|
|
if (!r.ok) break;
|
|
state = r.state;
|
|
if (state.phase !== "playing") return; // delivered and won outright
|
|
}
|
|
throw new Error("the clockwork hoarded its number instead of winning");
|
|
});
|
|
});
|
|
|
|
describe("bank guarding, teleport delivery, and turn theft", () => {
|
|
function rig() {
|
|
const players = ["foe", "bot"];
|
|
let { state } = createGame({ playerIds: players, seed: 42, sets: ["basic", "expansion1"] });
|
|
while (state.turn.round < 2 || state.players[state.turn.activeIndex]!.id !== "bot") {
|
|
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
|
if (!r.ok) throw new Error(r.error);
|
|
state = r.state;
|
|
}
|
|
return state;
|
|
}
|
|
|
|
it("a raider at the stocked bank's gates pulls the clockwork home", () => {
|
|
const state = rig();
|
|
const bot = state.players.find((p) => p.id === "bot")!;
|
|
const foe = state.players.find((p) => p.id === "foe")!;
|
|
const gold = state.treasures.find((t) => t.owner === "foe")!;
|
|
gold.position = { ...bot.home };
|
|
gold.carriedBy = null;
|
|
foe.position = { x: bot.home.x, y: bot.home.y + 1 };
|
|
bot.position = { ...foe.home };
|
|
bot.hand = [];
|
|
// The march may wind through the maze; what matters is that it ENDS
|
|
// at the threatened bank, not out on the gold map.
|
|
let cur = state;
|
|
for (let guard = 0; guard < 60; guard++) {
|
|
const seat = actingSeat(cur);
|
|
if (seat !== "bot") {
|
|
// The foe answers stacks with a pass and burns its turns.
|
|
let r2 = applyCommand(cur, seat, { type: "pass" });
|
|
if (!r2.ok) r2 = applyCommand(cur, seat, { type: "endTurn", draw: 0 });
|
|
if (!r2.ok) throw new Error(`foe stuck: ${r2.error}`);
|
|
cur = r2.state;
|
|
continue;
|
|
}
|
|
const view = viewFor(cur, "bot");
|
|
const cmd = automatonCommand(view, "hunter", "archmage") ?? automatonFallback(view, "archmage");
|
|
const r = applyCommand(cur, "bot", cmd);
|
|
if (!r.ok) throw new Error(`refused: ${JSON.stringify(cmd)} — ${r.error}`);
|
|
cur = r.state;
|
|
const at = cur.players.find((p) => p.id === "bot")!;
|
|
if (cellKey(at.position) === cellKey(bot.home)) return;
|
|
}
|
|
throw new Error("the clockwork never came home to its threatened bank");
|
|
});
|
|
|
|
it("carrying with home a blink away, it teleports the delivery", () => {
|
|
const state = rig();
|
|
const bot = state.players.find((p) => p.id === "bot")!;
|
|
const prize = state.treasures.find((t) => t.owner === "foe")!;
|
|
prize.carriedBy = "bot";
|
|
prize.position = null;
|
|
bot.carriedTreasureId = prize.id;
|
|
// Two squares from home as the spell flies, but walled off on foot.
|
|
bot.position = { x: bot.home.x, y: bot.home.y + 2 };
|
|
for (const side of ["N", "S", "E", "W"] as const) {
|
|
state.edgeOverrides[edgeKey(bot.position, side)] = "wall";
|
|
}
|
|
bot.hand = [{ cardId: "teleport", instanceId: "TP" }];
|
|
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
|
expect(cmd).toEqual({ type: "cast", instanceId: "TP", target: { kind: "cell", cell: { ...bot.home } } });
|
|
});
|
|
|
|
it("a would-win carrier eats the lightning, not the fireball", () => {
|
|
let state = rig();
|
|
const bot = state.players.find((p) => p.id === "bot")!;
|
|
const foe = state.players.find((p) => p.id === "foe")!;
|
|
// Foe has one banked and carries the second: one delivery from winning.
|
|
const banked = state.treasures.find((t) => t.owner === "bot")!;
|
|
banked.position = { ...foe.home };
|
|
const carried = state.treasures.filter((t) => t.owner === "bot")[1];
|
|
if (!carried) throw new Error("setup: the bot owns fewer than two treasures");
|
|
carried.carriedBy = "foe";
|
|
carried.position = null;
|
|
foe.carriedTreasureId = carried.id;
|
|
foe.position = { ...bot.position };
|
|
bot.hand = [
|
|
{ cardId: "fireball", instanceId: "FB" },
|
|
{ cardId: "lightning-blast", instanceId: "LB" },
|
|
{ cardId: "number-4", instanceId: "N4" },
|
|
];
|
|
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
|
expect(cmd).toMatchObject({ type: "cast", instanceId: "LB" });
|
|
});
|
|
});
|
|
|
|
describe("the denial planner offers only castable blocks", () => {
|
|
it("tacks are offered only at the caster's feet", () => {
|
|
// TACKS demand adjacency; a planner that proposes them at range has
|
|
// its cast refused every turn, and the refusal-fallback loop reads
|
|
// as an idle bot.
|
|
let { state } = createGame({ playerIds: ["foe", "bot"], seed: 42, sets: ["basic", "expansion1"] });
|
|
while (state.turn.round < 2 || state.players[state.turn.activeIndex]!.id !== "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")!;
|
|
// The bot's floor gold with a raider closing on it, and only TACKS
|
|
// in hand to deny the road — from a stand-off distance.
|
|
const gold = state.treasures.find((t) => t.owner === "bot" && t.position)!;
|
|
foe.position = { ...gold.position! };
|
|
bot.position = { ...bot.home };
|
|
bot.hand = [{ cardId: "handful-of-tacks", instanceId: "HT" }];
|
|
for (let guard = 0; guard < 6; guard++) {
|
|
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
|
if (!cmd) break;
|
|
const r = applyCommand(state, "bot", cmd);
|
|
// Whatever the brain proposes, the engine must accept it.
|
|
if (!r.ok) throw new Error(`brain proposed a refused command: ${JSON.stringify(cmd)} — ${r.error}`);
|
|
state = r.state;
|
|
if (cmd.type === "endTurn") break;
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("interception, escape, and hazard sense", () => {
|
|
function toBot(state: GameState): GameState {
|
|
while (state.turn.round < 2 || state.players[state.turn.activeIndex]!.id !== "bot") {
|
|
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
|
if (!r.ok) throw new Error(r.error);
|
|
state = r.state;
|
|
}
|
|
return state;
|
|
}
|
|
|
|
it("roots a carrier closing on home with LOCK IN PLACE", () => {
|
|
let { state } = createGame({ playerIds: ["foe", "bot"], seed: 42, sets: ["basic", "expansion1"] });
|
|
state = toBot(state);
|
|
const bot = state.players.find((p) => p.id === "bot")!;
|
|
const foe = state.players.find((p) => p.id === "foe")!;
|
|
// The foe stands beside the bot, laden, a short walk from banking.
|
|
foe.position = { x: bot.position.x, y: bot.position.y };
|
|
const gold = state.treasures.find((t) => t.owner === "bot" && t.position)!;
|
|
gold.position = null;
|
|
gold.carriedBy = "foe";
|
|
foe.carriedTreasureId = gold.id;
|
|
foe.home = { ...bot.position };
|
|
bot.hand = [{ cardId: "lock-in-place", instanceId: "LK" }];
|
|
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
|
expect(cmd).toMatchObject({ type: "cast", instanceId: "LK", target: { kind: "player", playerId: "foe" } });
|
|
expect(applyCommand(state, "bot", cmd!).ok).toBe(true);
|
|
});
|
|
|
|
it("cracks a safe over the prize instead of grabbing at the lid forever", () => {
|
|
let { state } = createGame({ playerIds: ["foe", "bot"], seed: 42, sets: ["basic", "expansion1"] });
|
|
state = toBot(state);
|
|
const bot = state.players.find((p) => p.id === "bot")!;
|
|
const chest = state.treasures.find((t) => t.owner === "foe" && t.position)!;
|
|
state.squareContents[cellKey(chest.position!)] = { kind: "safe", damage: 0, createdBy: "foe" };
|
|
bot.position = { ...chest.position! };
|
|
bot.hand = [{ cardId: "dispel-creation", instanceId: "DC" }];
|
|
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
|
expect(cmd).toMatchObject({ type: "cast", instanceId: "DC", target: { kind: "cell" } });
|
|
const r = applyCommand(state, "bot", cmd!);
|
|
expect(r.ok).toBe(true);
|
|
// The box gone, the very next thought is the grab.
|
|
const next = automatonCommand(viewFor(r.state, "bot"), "hunter", "archmage");
|
|
expect(next).toMatchObject({ type: "pickUpTreasure" });
|
|
});
|
|
|
|
it("never proposes the doomed grab on a safe it cannot open", () => {
|
|
let { state } = createGame({ playerIds: ["foe", "bot"], seed: 42, sets: ["basic", "expansion1"] });
|
|
state = toBot(state);
|
|
const bot = state.players.find((p) => p.id === "bot")!;
|
|
const chest = state.treasures.find((t) => t.owner === "foe" && t.position)!;
|
|
state.squareContents[cellKey(chest.position!)] = { kind: "safe", damage: 0, createdBy: "foe" };
|
|
bot.position = { ...chest.position! };
|
|
bot.hand = [{ cardId: "number-3", instanceId: "N3" }];
|
|
for (let i = 0; i < 8; i++) {
|
|
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
|
if (!cmd) break;
|
|
expect(cmd.type).not.toBe("pickUpTreasure");
|
|
const r = applyCommand(state, "bot", cmd);
|
|
expect(r.ok).toBe(true);
|
|
state = r.state;
|
|
if (cmd.type === "endTurn") break;
|
|
}
|
|
});
|
|
|
|
it("a pressed carrier of any temperament turns to mist", () => {
|
|
let { state } = createGame({ playerIds: ["foe", "bot"], seed: 42, sets: ["basic", "expansion1"] });
|
|
state = toBot(state);
|
|
const bot = state.players.find((p) => p.id === "bot")!;
|
|
const foe = state.players.find((p) => p.id === "foe")!;
|
|
// Bot hauls the foe's gold mid-journey with the foe breathing down its neck.
|
|
const gold = state.treasures.find((t) => t.owner === "foe" && t.position)!;
|
|
gold.position = null;
|
|
gold.carriedBy = "bot";
|
|
bot.carriedTreasureId = gold.id;
|
|
bot.position = { ...foe.home };
|
|
foe.position = { ...bot.position };
|
|
bot.hand = [
|
|
{ cardId: "mist-body", instanceId: "MB" },
|
|
{ cardId: "number-3", instanceId: "N3" },
|
|
{ cardId: "number-5", instanceId: "N5" },
|
|
];
|
|
const cmd = automatonCommand(viewFor(state, "bot"), "berserker", "archmage");
|
|
expect(cmd).toMatchObject({ type: "cast", instanceId: "MB", numberInstanceIds: ["N5"] });
|
|
const r = applyCommand(state, "bot", cmd!);
|
|
if (!r.ok) throw new Error(r.error);
|
|
expect(r.state.sustained.some((s) => s.cardId === "mist-body" && s.targetId === "bot")).toBe(true);
|
|
});
|
|
|
|
it("detours around a thornbush jail rather than diving in", () => {
|
|
let { state } = createGame({ playerIds: ["bot", "foe"], seed: 7, sets: ["basic", "expansion1"] });
|
|
state = toBot(state);
|
|
const bot = state.players.find((p) => p.id === "bot")!;
|
|
bot.hand = [];
|
|
// Two exits from the bot's square: the short road east holds a
|
|
// thornbush, the long road stays clean. A jail is not a shortcut.
|
|
const sides = ["N", "S", "E", "W"] as const;
|
|
const open = sides.filter((s) => {
|
|
const n = { x: bot.position.x + (s === "E" ? 1 : s === "W" ? -1 : 0),
|
|
y: bot.position.y + (s === "S" ? 1 : s === "N" ? -1 : 0) };
|
|
return state.board.cells[cellKey(n)] !== undefined;
|
|
});
|
|
expect(open.length).toBeGreaterThanOrEqual(2);
|
|
const bushSide = open[0]!;
|
|
const freeSide = open[1]!;
|
|
for (const s2 of sides) {
|
|
state.edgeOverrides[edgeKey(bot.position, s2)] =
|
|
s2 === bushSide || s2 === freeSide ? "open" : "wall";
|
|
}
|
|
const bushCell = { x: bot.position.x + (bushSide === "E" ? 1 : bushSide === "W" ? -1 : 0),
|
|
y: bot.position.y + (bushSide === "S" ? 1 : bushSide === "N" ? -1 : 0) };
|
|
state.squareContents[cellKey(bushCell)] = { kind: "thornbush", damage: 0, createdBy: "foe" };
|
|
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
|
// Whatever goal it picks, the first stride must not be into the bush.
|
|
expect(cmd).toMatchObject({ type: "move" });
|
|
expect((cmd as { direction: string }).direction).not.toBe(bushSide);
|
|
expect(applyCommand(state, "bot", cmd!).ok).toBe(true);
|
|
});
|
|
});
|