Since 6e49fc9. Engine: the teleport BFS in the view and the engine's
wallIgnoringDistance share teleportStep; the counter trail is weighed
between steps instead of on six arms; the bush attack takes its
adjacency from SIDES like the wall does; a dead redirected clause, a
one-member set, an identity ternary, and a stray try/catch are gone;
two tests now assert what their names promise. Server: KEEPER and
MAX_SEATS live in rooms.ts, the three Sentry bells ring through one
helper, the unread reply field is dropped, actingSeat comes from the
engine, and the protocol header names every message. Web: ReplayStep
is one type, cardName one helper, the say box and the reference section
one snippet each, the keeper's name arrives from the server rather than
the source, and the lifesaver ruling that ruled nothing is out.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
1276 lines
59 KiB
TypeScript
1276 lines
59 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, drawUnderSlowDeath, interceptCell, pathToward, 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.pushPending?.pusheeId ??
|
|
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);
|
|
});
|
|
});
|
|
|
|
describe("the clockwork under a curse, and before a pit", () => {
|
|
/** A two-seat game advanced to the bot's own turn in round 2. */
|
|
function botsTurn() {
|
|
let { state } = createGame({ playerIds: ["human", "bot"], seed: 7, sets: ["basic", "expansion1"] });
|
|
for (let guard = 0; guard < 8; guard++) {
|
|
if (state.turn.round >= 2 && actingSeat(state) === "bot") break;
|
|
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
|
if (!r.ok) throw new Error(`setup: ${r.error}`);
|
|
state = r.state;
|
|
}
|
|
return state;
|
|
}
|
|
|
|
it("draws only what SLOW DEATH lets it afford", () => {
|
|
const state = botsTurn();
|
|
const bot = state.players.find((p) => p.id === "bot")!;
|
|
const plain = viewFor(state, "bot");
|
|
expect(drawUnderSlowDeath(plain, 2)).toBe(2);
|
|
pushSustained(state, { id: "sd", cardId: "slow-death", casterId: "human", targetId: "bot", remainingTurns: 1e9 });
|
|
bot.life = 7;
|
|
expect(drawUnderSlowDeath(viewFor(state, "bot"), 2)).toBe(2);
|
|
bot.life = 5;
|
|
expect(drawUnderSlowDeath(viewFor(state, "bot"), 2)).toBe(1);
|
|
bot.life = 4;
|
|
expect(drawUnderSlowDeath(viewFor(state, "bot"), 2)).toBe(0);
|
|
});
|
|
|
|
it("raises no SHADOW while bleeding, nor with little blood to spare", () => {
|
|
for (const rig of [
|
|
(s: GameState) => pushSustained(s, { id: "sd", cardId: "slow-death", casterId: "human", targetId: "bot", remainingTurns: 1e9 }),
|
|
(s: GameState) => { s.players.find((p) => p.id === "bot")!.life = 5; },
|
|
]) {
|
|
let state = botsTurn();
|
|
rig(state);
|
|
const bot = state.players.find((p) => p.id === "bot")!;
|
|
bot.hand = [{ instanceId: "shadow#T", cardId: "shadow" }];
|
|
// Play the bot's whole turn: nothing it does may be the shadow.
|
|
for (let guard = 0; guard < 30 && actingSeat(state) === "bot"; guard++) {
|
|
const view = viewFor(state, "bot");
|
|
const cmd = automatonCommand(view, "berserker", "archmage") ?? automatonFallback(view, "archmage");
|
|
expect(cmd.type === "cast" && cmd.instanceId === "shadow#T").toBe(false);
|
|
const r = applyCommand(state, "bot", cmd);
|
|
if (!r.ok) break;
|
|
state = r.state;
|
|
}
|
|
}
|
|
});
|
|
|
|
it("never plans a stride into a pit it cannot leap beyond", () => {
|
|
const state = botsTurn();
|
|
const bot = state.players.find((p) => p.id === "bot")!;
|
|
const view0 = viewFor(state, "bot");
|
|
// Stand the bot at the head of a straight three-square run, dig a pit
|
|
// in the middle square, and wall the far one with stone: the route in
|
|
// from here is no route. With the stone gone, the leap is a road again.
|
|
const delta = { N: { x: 0, y: -1 }, E: { x: 1, y: 0 }, S: { x: 0, y: 1 }, W: { x: -1, y: 0 } } as const;
|
|
for (const k of Object.keys(view0.board.cells)) {
|
|
const [x, y] = k.split(",").map(Number) as [number, number];
|
|
for (const dir of ["N", "E", "S", "W"] as const) {
|
|
const pit = { x: x + delta[dir].x, y: y + delta[dir].y };
|
|
const beyond = { x: pit.x + delta[dir].x, y: pit.y + delta[dir].y };
|
|
if (!view0.board.cells[cellKey(pit)] || !view0.board.cells[cellKey(beyond)]) continue;
|
|
if ((view0.board.edges[edgeKey({ x, y }, dir)] ?? "open") !== "open") continue;
|
|
if ((view0.board.edges[edgeKey(pit, dir)] ?? "open") !== "open") continue;
|
|
if (state.squareContents[k] || state.squareContents[cellKey(pit)] || state.squareContents[cellKey(beyond)]) continue;
|
|
bot.position = { x, y };
|
|
state.squareContents[cellKey(pit)] = { kind: "pit", damage: 0, createdBy: "human" };
|
|
state.squareContents[cellKey(beyond)] = { kind: "stone", damage: 0, createdBy: "human" };
|
|
const blocked = pathToward(viewFor(state, "bot"), bot.position, new Set([cellKey(pit)]), { throughHazards: true });
|
|
expect(blocked === null || blocked.dir !== dir).toBe(true);
|
|
delete state.squareContents[cellKey(beyond)];
|
|
const open = pathToward(viewFor(state, "bot"), bot.position, new Set([cellKey(pit)]), { throughHazards: true });
|
|
expect(open?.dir).toBe(dir);
|
|
return;
|
|
}
|
|
}
|
|
throw new Error("no straight three-square run on this board");
|
|
});
|
|
});
|
|
|
|
describe("the common enemy and the interception", () => {
|
|
/** Past the opening round, at the bot's turn, every hand emptied. */
|
|
function tableOf(players: PlayerId[], seed = 7): GameState {
|
|
let { state } = createGame({ playerIds: players, seed, sets: ["basic", "expansion1"] });
|
|
for (let i = 0; i < players.length; 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;
|
|
}
|
|
for (const p of state.players) p.hand = [];
|
|
return state;
|
|
}
|
|
const bank = (state: GameState, owner: PlayerId, at: PlayerId) => {
|
|
const t = state.treasures.find((t) => t.owner === owner && t.position && !t.carriedBy)!;
|
|
t.position = { ...state.players.find((p) => p.id === at)!.home };
|
|
};
|
|
|
|
it("aims at the wizard with more banked than itself when no kill is on the table", () => {
|
|
const state = tableOf(["bot", "weak", "leader"]);
|
|
const bot = state.players.find((p) => p.id === "bot")!;
|
|
for (const id of ["weak", "leader"]) state.players.find((p) => p.id === id)!.position = { ...bot.position };
|
|
state.players.find((p) => p.id === "weak")!.life = 10;
|
|
state.players.find((p) => p.id === "leader")!.life = 12;
|
|
bank(state, "weak", "leader");
|
|
bot.hand = [{ instanceId: "fireball#T", cardId: "fireball" }];
|
|
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
|
expect(cmd).toMatchObject({ type: "cast", instanceId: "fireball#T", target: { kind: "player", playerId: "leader" } });
|
|
});
|
|
|
|
it("still takes a kill over the leader", () => {
|
|
const state = tableOf(["bot", "weak", "leader"]);
|
|
const bot = state.players.find((p) => p.id === "bot")!;
|
|
for (const id of ["weak", "leader"]) state.players.find((p) => p.id === id)!.position = { ...bot.position };
|
|
state.players.find((p) => p.id === "weak")!.life = 5;
|
|
state.players.find((p) => p.id === "leader")!.life = 12;
|
|
bank(state, "weak", "leader");
|
|
bot.hand = [{ instanceId: "fireball#T", cardId: "fireball" }];
|
|
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
|
expect(cmd).toMatchObject({ type: "cast", target: { kind: "player", playerId: "weak" } });
|
|
});
|
|
|
|
it("cuts off a delivery that would win: their home when it can get there first, else their road", () => {
|
|
const state = tableOf(["bot", "foe"], 5);
|
|
const bot = state.players.find((p) => p.id === "bot")!;
|
|
const foe = state.players.find((p) => p.id === "foe")!;
|
|
// The foe has one banked and carries the second, far from home.
|
|
bank(state, "bot", "foe");
|
|
const second = state.treasures.find((t) => t.owner === "bot" && t.position && !t.carriedBy &&
|
|
cellKey(t.position) !== cellKey(foe.home))!;
|
|
second.position = null; second.carriedBy = "foe"; foe.carriedTreasureId = second.id;
|
|
foe.position = { ...bot.home };
|
|
bot.position = { x: foe.home.x, y: foe.home.y === 0 ? 1 : foe.home.y - 1 };
|
|
const view = viewFor(state, "bot");
|
|
const cut = interceptCell(view, foe, bot.position, false);
|
|
expect(cellKey(cut)).toBe(cellKey(foe.home));
|
|
// The march heads there — and, arrived, lifts the banked gold off
|
|
// their doorstep, which unmakes the win outright.
|
|
const step = pathToward(view, bot.position, new Set([cellKey(cut)]), {});
|
|
expect(automatonCommand(view, "hunter", "archmage")).toEqual({ type: "move", direction: step!.dir });
|
|
bot.position = { ...foe.home };
|
|
expect(automatonCommand(viewFor(state, "bot"), "hunter", "archmage")).toMatchObject({ type: "pickUpTreasure" });
|
|
});
|
|
|
|
it("leaves a carrier alone whose delivery would not win", () => {
|
|
const state = tableOf(["bot", "foe"], 5);
|
|
const foe = state.players.find((p) => p.id === "foe")!;
|
|
const forGold = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
|
const forFoe = automatonCommand(viewFor(state, "bot"), "berserker", "archmage");
|
|
expect(forGold).not.toEqual(forFoe);
|
|
const t = state.treasures.find((t) => t.owner === "bot" && t.position && !t.carriedBy)!;
|
|
t.position = null; t.carriedBy = "foe"; foe.carriedTreasureId = t.id;
|
|
// Nothing banked: the foe is merely a thief of my gold, and with no
|
|
// attack in hand there is no chase — the hunter's march is the gold's.
|
|
expect(automatonCommand(viewFor(state, "bot"), "hunter", "archmage")).toEqual(forGold);
|
|
});
|
|
});
|