Pass Through Wall joins the march: the bot banks a crossing when stepping through one wall beats the walk by 4+ steps (or no road exists), walks to the chosen wall, and spends the charge stepping through. passWallCharges is now in PlayerPublicView — the cast is public at a physical table. Path denial for guardGold tiers: when an enemy carries the bot's treasure toward home, or closes on its gold on the floor, the bot reconstructs the threat's shortest path and prices every corridor line and empty square on it; CREATE WALL, FILL SQUARE WITH STONE, or THORNBUSH lands wherever the detour costs the enemy 3+ steps, legality mirrored from the engine (sighted, empty, off homes and warp tokens). All five cards leave the bottom discard tier (2 -> 6). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
333 lines
14 KiB
TypeScript
333 lines
14 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: `illusion-wall#${i}`, cardId: "illusion-wall" }
|
|
));
|
|
// 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: "buddy#T", cardId: "buddy" },
|
|
{ instanceId: "ugly#T", cardId: "ugly" },
|
|
{ instanceId: "fear#T", cardId: "fear" },
|
|
{ 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);
|
|
});
|
|
});
|