'Players or monsters must move away from you on their turn if within range, if they can.' Automatons caught in the dread now spend their legs escaping before any other marching — the step that most increases distance, a true dead end excusing them per the card. Humans keep their freedom but get the compulsion spelled out in an urgent slip (move away, even by card or through danger; only a dead end excuses you), with the maze trusting the table to honor it — the owner's chosen depth of enforcement. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
551 lines
24 KiB
TypeScript
551 lines
24 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
applyCommand,
|
|
createGame,
|
|
type GameState,
|
|
type PlayerId,
|
|
} from "../src/game";
|
|
import { cellKey, edgeKey } from "../src/board";
|
|
import { viewFor } from "../src/view";
|
|
import { automatonCommand, automatonFallback, type AutomatonStyle, type AutomatonTier } from "../src/automaton";
|
|
|
|
/** Whose input does the maze want right now? */
|
|
function actingSeat(state: GameState): PlayerId {
|
|
return (
|
|
state.stack?.waitingOn ??
|
|
state.pendingDiscard ??
|
|
state.chaosPending?.queue[0] ??
|
|
state.outOfTurnWindow?.playerId ??
|
|
state.players[state.turn.activeIndex]!.id
|
|
);
|
|
}
|
|
|
|
/** Drive a full bot-vs-bot game; returns the final state and command count. */
|
|
function playOut(
|
|
seed: number, players: number, expansion = true,
|
|
styles: AutomatonStyle[] = [], tiers: AutomatonTier[] = [],
|
|
) {
|
|
const ids = Array.from({ length: players }, (_, i) => `bot${i + 1}`);
|
|
const styleOf = new Map(ids.map((id, i) => [id, styles[i] ?? "hunter"]));
|
|
const tierOf = new Map(ids.map((id, i) => [id, tiers[i] ?? "archmage"]));
|
|
let { state } = createGame({
|
|
playerIds: ids,
|
|
seed,
|
|
sets: expansion ? ["basic", "expansion1"] : ["basic"],
|
|
deckRev: 8,
|
|
});
|
|
let commands = 0;
|
|
let stuck = 0;
|
|
const CAP = 4000;
|
|
while (state.phase === "playing" && commands < CAP) {
|
|
const seat = actingSeat(state);
|
|
const view = viewFor(state, seat);
|
|
const tier = tierOf.get(seat);
|
|
const chosen = automatonCommand(view, styleOf.get(seat), tier);
|
|
const cmd = chosen ?? automatonFallback(view, tier);
|
|
let r = applyCommand(state, seat, cmd);
|
|
if (!r.ok) {
|
|
// Retry with the fallback only if the fallback was not what just failed.
|
|
const fb = automatonFallback(view, tier);
|
|
if (chosen) r = applyCommand(state, seat, fb);
|
|
if (!r.ok) {
|
|
stuck++;
|
|
if (stuck > 3) {
|
|
throw new Error(
|
|
`automaton stuck at seat ${seat} after ${commands} commands: ` +
|
|
`${JSON.stringify(cmd)} -> ${JSON.stringify(fb)} both refused (${r.error})`,
|
|
);
|
|
}
|
|
// Last-ditch: burn the turn structure forward.
|
|
r = applyCommand(state, seat, { type: "endTurn", draw: 0 });
|
|
if (!r.ok) r = applyCommand(state, seat, { type: "pass" });
|
|
if (!r.ok) throw new Error(`unrecoverable at ${seat}: ${r.error}`);
|
|
}
|
|
} else {
|
|
stuck = 0;
|
|
}
|
|
state = r.state;
|
|
commands++;
|
|
}
|
|
return { state, commands };
|
|
}
|
|
|
|
describe("automaton vs automaton", () => {
|
|
it("two clockwork wizards fight a game to its end", () => {
|
|
const { state } = playOut(11, 2);
|
|
expect(state.phase).toBe("finished");
|
|
expect(state.winner).not.toBeNull();
|
|
});
|
|
|
|
it("holds up across many seeds without wedging", () => {
|
|
let finished = 0;
|
|
for (const seed of [1, 2, 3, 5, 8, 13, 21, 34]) {
|
|
const { state } = playOut(seed, 2);
|
|
if (state.phase === "finished") finished++;
|
|
}
|
|
// Cautious clockwork can stall a maze; most games must still conclude.
|
|
expect(finished).toBeGreaterThanOrEqual(6);
|
|
});
|
|
|
|
it("a full table of four automatons concludes", () => {
|
|
const { state } = playOut(7, 4);
|
|
expect(state.phase).toBe("finished");
|
|
});
|
|
|
|
it("the apprentice handicap bites where cards decide: combat mirrors", () => {
|
|
// Deterministic across these seeds: same brains, same dice.
|
|
let arch = 0, appr = 0;
|
|
for (const seed of [1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233]) {
|
|
const { state } = playOut(seed, 2, true,
|
|
["berserker", "berserker"], ["archmage", "apprentice"]);
|
|
if (state.winner === "bot1") arch++;
|
|
if (state.winner === "bot2") appr++;
|
|
}
|
|
expect(arch).toBeGreaterThan(appr);
|
|
});
|
|
|
|
it("every temperament finishes its wars", () => {
|
|
for (const styles of [
|
|
["berserker", "hunter"], ["worrier", "hunter"], ["berserker", "worrier"],
|
|
] as AutomatonStyle[][]) {
|
|
let finished = 0;
|
|
for (const seed of [3, 17, 29]) {
|
|
const { state } = playOut(seed, 2, true, styles);
|
|
if (state.phase === "finished") finished++;
|
|
}
|
|
expect(finished).toBeGreaterThanOrEqual(2);
|
|
}
|
|
});
|
|
});
|
|
|
|
function underAttackShared(attackId: string, defenderHand: { instanceId: string; cardId: string }[], rig?: (s: GameState, defender: string) => void) {
|
|
let { state } = createGame({ playerIds: ["human", "bot"], seed: 42, sets: ["basic", "expansion1"], deckRev: 13 });
|
|
// burn round 1
|
|
for (let i = 0; i < 2; i++) {
|
|
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
|
if (!r.ok) throw new Error(`setup: ${r.error}`);
|
|
state = r.state;
|
|
}
|
|
while (actingSeat(state) !== "human") {
|
|
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
|
if (!r.ok) throw new Error(`setup: ${r.error}`);
|
|
state = r.state;
|
|
}
|
|
const human = state.players.find((p) => p.id === "human")!;
|
|
const bot = state.players.find((p) => p.id === "bot")!;
|
|
bot.position = { ...human.position };
|
|
defenderHand.forEach((c, i) => { bot.hand[i] = c; });
|
|
human.hand[0] = { instanceId: `${attackId}#A`, cardId: attackId };
|
|
rig?.(state, "bot");
|
|
const r = applyCommand(state, "human", {
|
|
type: "cast", instanceId: `${attackId}#A`, target: { kind: "player", playerId: "bot" },
|
|
...(attackId === "drop-object" ? { params: { cardId: "dagger" } } : {}),
|
|
});
|
|
if (!r.ok) throw new Error(`setup: ${r.error}`);
|
|
return r.state;
|
|
}
|
|
|
|
describe("the clockwork does not waste counters on pointless targets", () => {
|
|
it("passes on drop-object rather than absorbing nothing", () => {
|
|
const state = underAttackShared("drop-object", [
|
|
{ instanceId: "absorb#T", cardId: "absorb" },
|
|
{ instanceId: "blunt#T", cardId: "blunt" },
|
|
{ instanceId: "dagger#T", cardId: "dagger" },
|
|
]);
|
|
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
|
expect(cmd).toEqual({ type: "pass" });
|
|
});
|
|
|
|
it("shields a drop-object aimed at its carried treasure", () => {
|
|
const state = underAttackShared("drop-object", [
|
|
{ instanceId: "full-shield#T", cardId: "full-shield" },
|
|
{ instanceId: "dagger#T", cardId: "dagger" },
|
|
], (s, defender) => {
|
|
const d = s.players.find((p) => p.id === defender)!;
|
|
const t = s.treasures.find((t) => t.owner !== defender)!;
|
|
t.position = null; t.carriedBy = defender; d.carriedTreasureId = t.id;
|
|
});
|
|
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
|
expect(cmd).toEqual({ type: "counteract", instanceId: "full-shield#T" });
|
|
});
|
|
});
|
|
|
|
describe("a clogged hand gets shed, not hoarded", () => {
|
|
it("the bot discards dead weight so the end-of-turn draw has room", () => {
|
|
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"], deckRev: 13 });
|
|
while (actingSeat(state) !== "bot") {
|
|
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
|
if (!r.ok) throw new Error(r.error);
|
|
state = r.state;
|
|
}
|
|
const bot = state.players.find((p) => p.id === "bot")!;
|
|
// Seven situational neutrals the brain has no play for: a dead hand.
|
|
bot.hand = Array.from({ length: 7 }, (_, i) => (
|
|
{ instanceId: `rotate-sector#${i}`, cardId: "rotate-sector" }
|
|
));
|
|
// Walk the bot's turn until it wants to end: it must shed before drawing.
|
|
for (let guard = 0; guard < 30; guard++) {
|
|
const view = viewFor(state, "bot");
|
|
const cmd = automatonCommand(view, "hunter", "archmage")!;
|
|
if (cmd.type === "discard") {
|
|
expect(cmd.instanceIds.length).toBe(2);
|
|
const r = applyCommand(state, "bot", cmd);
|
|
if (!r.ok) throw new Error(r.error);
|
|
state = r.state;
|
|
const next = automatonCommand(viewFor(state, "bot"), "hunter", "archmage")!;
|
|
expect(next).toEqual({ type: "endTurn", draw: 2 });
|
|
return;
|
|
}
|
|
if (cmd.type === "endTurn") throw new Error("ended the turn without shedding a dead hand");
|
|
const r = applyCommand(state, "bot", cmd);
|
|
if (!r.ok) throw new Error(`${cmd.type}: ${r.error}`);
|
|
state = r.state;
|
|
}
|
|
throw new Error("never reached the end of the turn");
|
|
});
|
|
});
|
|
|
|
describe("the clockwork honors absorb's fine print", () => {
|
|
it("answers NO SPELL with blunt, never absorb — durations soak no points", () => {
|
|
const state = underAttackShared("no-spell", [
|
|
{ instanceId: "absorb#T", cardId: "absorb" },
|
|
{ instanceId: "blunt#T", cardId: "blunt" },
|
|
]);
|
|
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
|
expect(cmd).toEqual({ type: "counteract", instanceId: "blunt#T" });
|
|
});
|
|
});
|
|
|
|
describe("the clockwork wields destroy wall", () => {
|
|
it("blasts its way out when no road leads to the gold", () => {
|
|
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"], deckRev: 24 });
|
|
while (actingSeat(state) !== "bot") {
|
|
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
|
if (!r.ok) throw new Error(r.error);
|
|
state = r.state;
|
|
}
|
|
const bot = state.players.find((p) => p.id === "bot")!;
|
|
// Brick the bot into a one-square cell far from everything.
|
|
bot.position = { x: 4, y: 4 };
|
|
for (const side of ["N", "S", "E", "W"] as const) {
|
|
state.edgeOverrides[edgeKey(bot.position, side)] = "wall";
|
|
}
|
|
state.players.find((p) => p.id === "other")!.position = { x: 0, y: 0 };
|
|
bot.hand[0] = { instanceId: "destroy-wall#T", cardId: "destroy-wall" };
|
|
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
|
expect(cmd).toMatchObject({ type: "cast", instanceId: "destroy-wall#T" });
|
|
// And the engine accepts the blast it chose.
|
|
const r = applyCommand(state, "bot", cmd!);
|
|
expect(r.ok).toBe(true);
|
|
});
|
|
|
|
it("values destroy wall above the chaff when forced to discard", () => {
|
|
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"], deckRev: 24 });
|
|
const bot = state.players.find((p) => p.id === "bot")!;
|
|
bot.hand = [
|
|
{ instanceId: "destroy-wall#T", cardId: "destroy-wall" },
|
|
{ instanceId: "trader#T", cardId: "trader" },
|
|
{ instanceId: "strength#T", cardId: "strength" },
|
|
{ instanceId: "adrenaline#T", cardId: "adrenaline" },
|
|
{ instanceId: "full-shield#T", cardId: "full-shield" },
|
|
{ instanceId: "fireball#T", cardId: "fireball" },
|
|
{ instanceId: "number-3#T", cardId: "number-3" },
|
|
{ instanceId: "troll#T", cardId: "troll" },
|
|
];
|
|
state.pendingDiscard = "bot";
|
|
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
|
expect(cmd?.type).toBe("discard");
|
|
if (cmd?.type === "discard") {
|
|
expect(cmd.instanceIds).not.toContain("destroy-wall#T");
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("the clockwork wields pass through wall", () => {
|
|
it("banks a crossing when bricked in, then steps through the wall", () => {
|
|
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"], deckRev: 24 });
|
|
while (actingSeat(state) !== "bot") {
|
|
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
|
if (!r.ok) throw new Error(r.error);
|
|
state = r.state;
|
|
}
|
|
const bot = state.players.find((p) => p.id === "bot")!;
|
|
bot.position = { x: 4, y: 4 };
|
|
for (const side of ["N", "S", "E", "W"] as const) {
|
|
state.edgeOverrides[edgeKey(bot.position, side)] = "wall";
|
|
}
|
|
state.players.find((p) => p.id === "other")!.position = { x: 0, y: 0 };
|
|
bot.hand[0] = { instanceId: "pass-through-wall#T", cardId: "pass-through-wall" };
|
|
const cast = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
|
expect(cast).toMatchObject({ type: "cast", instanceId: "pass-through-wall#T" });
|
|
let r = applyCommand(state, "bot", cast!);
|
|
if (!r.ok) throw new Error(r.error);
|
|
state = r.state;
|
|
expect(state.players.find((p) => p.id === "bot")!.passWallCharges).toBe(1);
|
|
// The charge is spent on a step through the bricks.
|
|
const step = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
|
expect(step?.type).toBe("move");
|
|
r = applyCommand(state, "bot", step!);
|
|
if (!r.ok) throw new Error(r.error);
|
|
const after = r.state.players.find((p) => p.id === "bot")!;
|
|
expect(cellKey(after.position)).not.toBe(cellKey({ x: 4, y: 4 }));
|
|
expect(after.passWallCharges).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("the clockwork denies the road", () => {
|
|
it("walls a thief's corridor when its treasure is being carried home", () => {
|
|
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"], deckRev: 24 });
|
|
while (actingSeat(state) !== "bot") {
|
|
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
|
if (!r.ok) throw new Error(r.error);
|
|
state = r.state;
|
|
}
|
|
const bot = state.players.find((p) => p.id === "bot")!;
|
|
const thief = state.players.find((p) => p.id === "other")!;
|
|
// The thief carries the bot's treasure down a one-lane tube to its home.
|
|
const t = state.treasures.find((t) => t.owner === "bot")!;
|
|
t.position = null;
|
|
t.carriedBy = "other";
|
|
thief.carriedTreasureId = t.id;
|
|
thief.position = { x: 4, y: 2 };
|
|
thief.home = { x: 4, y: 6 };
|
|
for (let y = 2; y <= 6; y++) {
|
|
state.edgeOverrides[edgeKey({ x: 4, y }, "E")] = "wall";
|
|
state.edgeOverrides[edgeKey({ x: 4, y }, "W")] = "wall";
|
|
}
|
|
state.edgeOverrides[edgeKey({ x: 4, y: 6 }, "S")] = "wall";
|
|
for (let y = 2; y <= 5; y++) {
|
|
state.edgeOverrides[edgeKey({ x: 4, y }, "S")] = "open";
|
|
}
|
|
bot.position = { x: 4, y: 4 };
|
|
bot.hand = [{ instanceId: "create-wall#T", cardId: "create-wall" }];
|
|
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
|
expect(cmd).toMatchObject({ type: "cast", instanceId: "create-wall#T" });
|
|
const r = applyCommand(state, "bot", cmd!);
|
|
if (!r.ok) throw new Error(r.error);
|
|
// The blockade must actually sever the thief's road home.
|
|
const target = (cmd as { target: { cell: { x: number; y: number }; side: string } }).target;
|
|
expect(["N", "S"]).toContain(target.side);
|
|
expect(target.cell.x).toBe(4);
|
|
});
|
|
});
|
|
|
|
describe("the widened spellbook", () => {
|
|
it("dispels a conjured wall standing between it and the only road", () => {
|
|
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"], deckRev: 24 });
|
|
while (actingSeat(state) !== "bot") {
|
|
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
|
if (!r.ok) throw new Error(r.error);
|
|
state = r.state;
|
|
}
|
|
const bot = state.players.find((p) => p.id === "bot")!;
|
|
bot.position = { x: 4, y: 4 };
|
|
for (const side of ["N", "S", "E", "W"] as const) {
|
|
const k = edgeKey(bot.position, side);
|
|
state.edgeOverrides[k] = "wall";
|
|
state.createdEdges[k] = true;
|
|
}
|
|
state.players.find((p) => p.id === "other")!.position = { x: 0, y: 0 };
|
|
bot.hand = [{ instanceId: "dispel-creation#T", cardId: "dispel-creation" }];
|
|
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
|
expect(cmd).toMatchObject({ type: "cast", instanceId: "dispel-creation#T" });
|
|
const r = applyCommand(state, "bot", cmd!);
|
|
if (!r.ok) throw new Error(r.error);
|
|
});
|
|
|
|
it("offers a buddy pact to the hound at its heels", () => {
|
|
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"], deckRev: 24 });
|
|
while (actingSeat(state) !== "bot") {
|
|
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
|
if (!r.ok) throw new Error(r.error);
|
|
state = r.state;
|
|
}
|
|
const bot = state.players.find((p) => p.id === "bot")!;
|
|
const hound = state.players.find((p) => p.id === "other")!;
|
|
hound.position = { ...bot.position };
|
|
bot.hand = [{ instanceId: "buddy#T", cardId: "buddy" }];
|
|
const cmd = automatonCommand(viewFor(state, "bot"), "worrier", "archmage");
|
|
expect(cmd).toEqual({
|
|
type: "cast", instanceId: "buddy#T",
|
|
target: { kind: "player", playerId: "other" },
|
|
});
|
|
const r = applyCommand(state, "bot", cmd!);
|
|
if (!r.ok) throw new Error(r.error);
|
|
});
|
|
});
|
|
|
|
describe("round two of the owner's tactics", () => {
|
|
it("exiles a thief carrying its gold to the far end of nowhere", () => {
|
|
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"], deckRev: 24 });
|
|
// burn round 1 and reach the bot's turn
|
|
for (let guard = 0; guard < 10 && !(actingSeat(state) === "bot" && state.turn.round > 1); guard++) {
|
|
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
|
if (!r.ok) throw new Error(r.error);
|
|
state = r.state;
|
|
}
|
|
const bot = state.players.find((p) => p.id === "bot")!;
|
|
const thief = state.players.find((p) => p.id === "other")!;
|
|
const t = state.treasures.find((t) => t.owner === "bot")!;
|
|
t.position = null;
|
|
t.carriedBy = "other";
|
|
thief.carriedTreasureId = t.id;
|
|
// The thief is a step from delivering; the bot watches from beside them.
|
|
thief.position = { x: thief.home.x, y: thief.home.y === 0 ? 1 : thief.home.y - 1 };
|
|
bot.position = { ...thief.position };
|
|
bot.hand = [{ instanceId: "teleport-opponent#T", cardId: "teleport-opponent" }];
|
|
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
|
expect(cmd).toMatchObject({ type: "cast", instanceId: "teleport-opponent#T" });
|
|
const dest = (cmd as { params: { cell: { x: number; y: number } } }).params.cell;
|
|
// The chosen square is a long march from the thief's own home.
|
|
const d = Math.abs(dest.x - thief.home.x) + Math.abs(dest.y - thief.home.y);
|
|
expect(d).toBeGreaterThanOrEqual(5);
|
|
const r = applyCommand(state, "bot", cmd!);
|
|
if (!r.ok) throw new Error(r.error);
|
|
});
|
|
|
|
it("casts adrenaline when two blows finish what one cannot", () => {
|
|
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"], deckRev: 24 });
|
|
for (let guard = 0; guard < 10 && !(actingSeat(state) === "bot" && state.turn.round > 1); guard++) {
|
|
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
|
if (!r.ok) throw new Error(r.error);
|
|
state = r.state;
|
|
}
|
|
const bot = state.players.find((p) => p.id === "bot")!;
|
|
const prey = state.players.find((p) => p.id === "other")!;
|
|
prey.position = { ...bot.position };
|
|
prey.life = 7; // fireball (5) alone cannot; fireball + dagger (3) can
|
|
bot.hand = [
|
|
{ instanceId: "adrenaline#T", cardId: "adrenaline" },
|
|
{ instanceId: "fireball#T", cardId: "fireball" },
|
|
{ instanceId: "dagger#T", cardId: "dagger" },
|
|
{ instanceId: "number-2#T", cardId: "number-2" },
|
|
];
|
|
const cmd = automatonCommand(viewFor(state, "bot"), "berserker", "archmage");
|
|
expect(cmd).toMatchObject({ type: "cast", instanceId: "adrenaline#T" });
|
|
const r = applyCommand(state, "bot", cmd!);
|
|
if (!r.ok) throw new Error(r.error);
|
|
});
|
|
});
|
|
|
|
describe("the fireball-then-buddy lockout", () => {
|
|
it("burns the target, then signs the pact so they cannot hit back", () => {
|
|
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"], deckRev: 24 });
|
|
for (let guard = 0; guard < 10 && !(actingSeat(state) === "bot" && state.turn.round > 1); guard++) {
|
|
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
|
if (!r.ok) throw new Error(r.error);
|
|
state = r.state;
|
|
}
|
|
const bot = state.players.find((p) => p.id === "bot")!;
|
|
const prey = state.players.find((p) => p.id === "other")!;
|
|
prey.position = { ...bot.position };
|
|
bot.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"], deckRev: 25 });
|
|
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.
|
|
state.sustained.push({
|
|
id: "fx-test", cardId: "buddy", casterId: "bot", targetId: "other",
|
|
turnsLeft: 1000, edge: undefined,
|
|
} as never);
|
|
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"], deckRev: 29 });
|
|
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"], deckRev: 32 });
|
|
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
|
|
state.sustained.push({
|
|
id: "fx-fear", cardId: "fear", casterId: "grim", targetId: "grim",
|
|
remainingTurns: 5, data: {},
|
|
} as never);
|
|
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);
|
|
}
|
|
});
|
|
});
|