Eric beats the clockwork four games in five; these close the holes he walks through. The treasure race: no number card is hoarded for a war chest when no enemy stands within six squares, and never against the delivery that wins the game outright. The kill: bestAttack now shops every number and the amplify separately, preferring a killing blow at minimal spend (with three points of soak margin, or an ABSORB undoes it) over maximum splash, banking the big numbers for the next fight. The last stand: a blow that would be lethal escalates past every thrift threshold — any counter that can save the clockwork is cheap at that price, ABSORB's three soaked points included when they are exactly the margin. The race denial: TELEPORT OPPONENT and DROP OBJECT now fire on any carrier one delivery from winning, not just the thief of the bot's own gold. And the MASTER KEY goes on display the turn it is drawn, opening every workable lock on the march for free. A/B against the prior brain across 300 mirror games: parity (the features aim at human play patterns mirrors rarely exercise); three scenario tests pin the new instincts. Ships ungated — brains choose commands, ledgers record them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
859 lines
38 KiB
TypeScript
859 lines
38 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" } as never);
|
|
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 sharpened instincts", () => {
|
|
function botTurn(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" } as never);
|
|
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" } as never];
|
|
foe.hand.push({ cardId: "fireball", instanceId: "FB" } as never);
|
|
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
|
|
bot.hand = [
|
|
{ cardId: "fireball", instanceId: "FB" } as never,
|
|
{ cardId: "number-3", instanceId: "N3" } as never,
|
|
];
|
|
// Stand the bot a straight, open four squares from home.
|
|
const home = bot.home;
|
|
const column = [0, 1, 2, 3, 4].map((d) => ({ x: home.x, y: home.y + d }));
|
|
if (!column.every((c) => viewFor(state, "bot").board.cells[cellKey(c)])) return;
|
|
bot.position = { ...column[4]! };
|
|
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");
|
|
});
|
|
});
|