Server: turnFacts joins actingSeat's full priority order (a hanging ward or slow-death window now reads as the lobby's attention, stubs included), summarize and stubOf share one baseSummary, the stranded doc comment returns to summarize, the protocol contract names feedbackList, Sentry init moves below the imports it cannot precede, randomBytes joins its group, the feedback path hoists once with the operator-fields note. Engine: the safe's sight check trusts castSight alone, the locked-safe predicate becomes lockedSafeAt, the doomed-grab test drives both seats and must reach endTurn. Web: the twin safe handlers merge, paPoints joins the reset ritual with a derived burnable range, five decorative empty handlers and a dead class go, the hand earns real list semantics, fitHandCards cites Card.svelte's tokens, two wrap-sites reindent, feedbackReports moves home as a named type, and the chronicle's stay-mounted reason is stated. feedback-reply.sh adopts its siblings' set/usage conventions and speaks Node. Two test screenshots leave the repo root. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
1113 lines
50 KiB
TypeScript
1113 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.slowDeathPending?.playerId ??
|
|
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" }];
|
|
// Drive whichever seat the maze wants (a punched foe answers its own
|
|
// counteraction window). The invariants: the bot never proposes the
|
|
// doomed grab, and its turn actually ends instead of idling forever.
|
|
let ended = false;
|
|
for (let i = 0; i < 24 && !ended; i++) {
|
|
const seat = actingSeat(state);
|
|
const view = viewFor(state, seat);
|
|
const chosen = automatonCommand(view, "hunter", "archmage");
|
|
let cmd = chosen ?? automatonFallback(view, "archmage");
|
|
if (seat === "bot") expect(cmd.type).not.toBe("pickUpTreasure");
|
|
let r = applyCommand(state, seat, cmd);
|
|
if (!r.ok && chosen) {
|
|
cmd = automatonFallback(view, "archmage");
|
|
r = applyCommand(state, seat, cmd);
|
|
}
|
|
expect(r.ok).toBe(true);
|
|
state = r.state;
|
|
ended = seat === "bot" && cmd.type === "endTurn";
|
|
}
|
|
expect(ended).toBe(true);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|