The archmage cuts off a winning delivery and aims at the leader when no kill is on the table

Reuben's three wins today were walked home unseen: the clockwork
fought whoever stood in front of it. Now a delivery that would end the
game pulls every automaton toward that wizard's home (where the banked
gold is lifted off the doorstep) or the road to it, and among visible
targets the thief still dies first, a kill is still taken, and only then
does the aim shift to whoever has more treasures banked. Measured with
deploy/brain-trial.mjs, which seats the working tree's brain against an
older checkout's at one table.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
Eric Wagoner
2026-09-22 09:11:34 -04:00
co-authored by Claude Fable 5.1
parent 1b18dffd28
commit be96dd6047
3 changed files with 236 additions and 9 deletions
+76
View File
@@ -0,0 +1,76 @@
// Two brains at one table: the working tree's automaton against an older
// checkout's, over seeded games, to measure a brain change before it
// ships. The older engine only chooses; the working tree's engine rules
// the table, so both play the same game under the same law.
//
// npx tsx deploy/brain-trial.mjs <old-checkout> [seeds=30] [--table new,old,old,old] [--style hunter] [--basic]
//
// Make the old checkout with `git worktree add <dir> <commit>`. Each seed
// is played once per rotation of the table so seat order cancels out.
import { applyCommand, automatonFallback, createGame, viewFor } from "../packages/engine/src/index.ts";
import { pathToFileURL } from "node:url";
import { resolve } from "node:path";
const args = process.argv.slice(2);
const oldDir = args[0];
if (!oldDir || oldDir.startsWith("--")) { console.error("first argument: the old checkout's directory"); process.exit(2); }
const seeds = Number(args.find((a, i) => i > 0 && /^\d+$/.test(a)) ?? 30);
const table = (args.includes("--table") ? args[args.indexOf("--table") + 1] : "new,old").split(",");
const style = args.includes("--style") ? args[args.indexOf("--style") + 1] : "hunter";
const sets = args.includes("--basic") ? ["basic"] : ["basic", "expansion1"];
const old = await import(pathToFileURL(resolve(oldDir, "packages/engine/src/index.ts")).href);
const brains = {
new: (await import("../packages/engine/src/index.ts")).automatonCommand,
old: old.automatonCommand,
};
function actingSeat(state) {
return state.wardPending?.ownerId ?? state.pushPending?.pusheeId ?? state.slowDeathPending?.playerId
?? state.pendingDiscard ?? state.chaosPending?.queue[0] ?? state.stack?.waitingOn
?? state.outOfTurnWindow?.playerId ?? state.players[state.turn.activeIndex].id;
}
function playOut(seed, order) {
const ids = order.map((b, i) => `${b}${i + 1}`);
const brainOf = Object.fromEntries(ids.map((id, i) => [id, brains[order[i]]]));
let { state } = createGame({ playerIds: ids, seed, sets });
let commands = 0, stuck = 0;
while (state.phase === "playing" && commands < 6000) {
const seat = actingSeat(state);
const view = viewFor(state, seat);
const chosen = brainOf[seat](view, style, "archmage");
let r = applyCommand(state, seat, chosen ?? automatonFallback(view, "archmage"));
if (!r.ok) {
if (chosen) r = applyCommand(state, seat, automatonFallback(view, "archmage"));
if (!r.ok) {
if (++stuck > 3) throw new Error(`stuck at ${seat} after ${commands}: ${r.error}`);
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;
}
const wins = { new: 0, old: 0, none: 0 };
const reasons = {};
const t0 = Date.now();
let games = 0;
for (let seed = 1; seed <= seeds; seed++) {
for (let rot = 0; rot < table.length; rot++) {
const order = [...table.slice(rot), ...table.slice(0, rot)];
const state = playOut(seed, order);
games++;
const w = state.winner;
if (!w) wins.none++;
else { wins[w.replace(/\d+$/, "")]++; reasons[state.winReason] = (reasons[state.winReason] ?? 0) + 1; }
}
}
const share = (b) => table.filter((x) => x === b).length / table.length;
console.log(`${games} games (${table.join(" vs ")}, ${style}, ${sets.length === 1 ? "basic" : "expansion"}) in ${((Date.now() - t0) / 1000).toFixed(0)}s`);
console.log(`new ${wins.new} (${(100 * wins.new / games).toFixed(0)}%, seats ${(100 * share("new")).toFixed(0)}%) old ${wins.old} (${(100 * wins.old / games).toFixed(0)}%, seats ${(100 * share("old")).toFixed(0)}%) unfinished ${wins.none}`);
console.log(`won by ${JSON.stringify(reasons)}`);
+72 -3
View File
@@ -730,6 +730,52 @@ function deliveryWins(view: GameView, p: { id: PlayerId; home: Cell; carriedTrea
(t) => t.owner !== p.id && t.position && cellKey(t.position) === cellKey(p.home)); (t) => t.owner !== p.id && t.position && cellKey(t.position) === cellKey(p.home));
} }
/** Treasures a wizard has delivered: the scoreboard the table reads. */
function bankedBy(view: GameView, p: { id: PlayerId; home: Cell }): number {
return view.treasures.filter(
(t) => t.owner !== p.id && t.position && !t.carriedBy && cellKey(t.position) === cellKey(p.home)).length;
}
/** The enemy whose delivery in progress would end the game, if any. */
function winningCarrier(view: GameView) {
return livingEnemies(view).find((p) => deliveryWins(view, p)) ?? null;
}
/**
* Where to cut off a carrier. Their home square first, when the clockwork
* can get there no later than they can: it is the one square they must
* reach, and the gold already banked there is free for the taking, which
* unmakes the win outright. Otherwise the nearest square on their
* shortest road home that the clockwork reaches before they do; failing
* that, the home square anyway. Ties break toward their home, so the goal
* holds still as the clockwork walks. The carrier's road is measured as
* if every door opened for them.
*/
export function interceptCell(
view: GameView, carrier: { position: Cell; home: Cell }, from: Cell, canUnlock: boolean,
): Cell {
const fromHome = distancesFrom(view, [carrier.home], true);
const fromCarrier = distancesFrom(view, [carrier.position], true);
const fromMe = distancesFrom(view, [from], canUnlock);
const road = fromCarrier.get(cellKey(carrier.home));
if (road == null) return carrier.home;
const mineToHome = fromMe.get(cellKey(carrier.home));
if (mineToHome != null && mineToHome <= road) return carrier.home;
let best: { cell: Cell; mine: number; toHome: number } | null = null;
for (const k of Object.keys(view.board.cells)) {
const theirs = fromCarrier.get(k);
const toHome = fromHome.get(k);
const mine = fromMe.get(k);
if (theirs == null || toHome == null || mine == null) continue;
if (theirs + toHome !== road || mine > theirs) continue;
if (!best || mine < best.mine || (mine === best.mine && toHome < best.toHome)) {
const [x, y] = k.split(",").map(Number);
best = { cell: { x: x!, y: y! }, mine, toHome };
}
}
return best?.cell ?? carrier.home;
}
/** The wizard making off with MY gold, if any. */ /** The wizard making off with MY gold, if any. */
function thiefOfMine(view: GameView) { function thiefOfMine(view: GameView) {
const carriers = new Set( const carriers = new Set(
@@ -1455,9 +1501,22 @@ export function automatonCommand(
.filter((p) => sighted.has(cellKey(p.position)) && .filter((p) => sighted.has(cellKey(p.position)) &&
!shieldedFromMe(p.id) && !myPact(p.id) && !bushOrMist(view, p.id)); !shieldedFromMe(p.id) && !myPact(p.id) && !bushOrMist(view, p.id));
if (visible.length > 0) { if (visible.length > 0) {
const target = thief && visible.some((p) => p.id === thief.id) // The thief of my gold dies first, and a kill on the table is
? thief // taken. Otherwise the aim shifts to whoever is nearest to winning:
: visible.sort((a, b) => a.life - b.life)[0]!; // a delivery in progress that would end the game, then a wizard
// with more treasures banked than I have. The clockwork still
// plays its own game — it hunts the leader only when the weakest
// would not have died anyway.
const weakest = [...visible].sort((a, b) => a.life - b.life)[0]!;
const finisher = bestAttack(view, weakest.id, tier);
const runner = visible.find((p) => deliveryWins(view, p));
const myBank = bankedBy(view, self);
const leader = [...visible]
.filter((p) => bankedBy(view, p) > myBank)
.sort((a, b) => bankedBy(view, b) - bankedBy(view, a) || a.life - b.life)[0];
const target = thief && visible.some((p) => p.id === thief.id) ? thief
: finisher && finisher.damage >= weakest.life ? weakest
: runner ?? leader ?? weakest;
// DROP OBJECT shakes the treasure out of the hands that matter: // DROP OBJECT shakes the treasure out of the hands that matter:
// the thief of my gold, or any carrier whose delivery wins. // the thief of my gold, or any carrier whose delivery wins.
{ {
@@ -1698,10 +1757,20 @@ export function automatonCommand(
livingEnemies(view).some((p) => livingEnemies(view).some((p) =>
!p.carriedTreasureId && !p.carriedTreasureId &&
Math.abs(p.position.x - self.home.x) + Math.abs(p.position.y - self.home.y) <= 2); Math.abs(p.position.x - self.home.x) + Math.abs(p.position.y - self.home.y) <= 2);
// INTERCEPTION: a delivery that would end the game is every
// clockwork's business, whatever its temperament — the road to that
// wizard's home is the only march that matters, and the post, once
// reached, is held (the strike above fires the moment they walk into
// sight). My own delivery and my own stolen gold still come first.
const runner = !self.carriedTreasureId && !chasing && !cursedIdiot ? winningCarrier(view) : null;
const cutoff = runner ? interceptCell(view, runner, self.position, canUnlock) : null;
if (cutoff && cellKey(cutoff) === here) return endTurnDrawing(view, tier, style);
const objectives = cursedIdiot && ownGoldCells.size > 0 const objectives = cursedIdiot && ownGoldCells.size > 0
? ownGoldCells ? ownGoldCells
: chasing : chasing
? new Set([cellKey(thief.position)]) ? new Set([cellKey(thief.position)])
: cutoff
? new Set([cellKey(cutoff)])
: bankThreatened : bankThreatened
? new Set([cellKey(self.home)]) ? new Set([cellKey(self.home)])
: style === "berserker" && !self.carriedTreasureId : style === "berserker" && !self.carriedTreasureId
+83 -1
View File
@@ -8,7 +8,7 @@ import {
} from "../src/game"; } from "../src/game";
import { cellKey, edgeKey } from "../src/board"; import { cellKey, edgeKey } from "../src/board";
import { sightedCellsFor, viewFor } from "../src/view"; import { sightedCellsFor, viewFor } from "../src/view";
import { automatonCommand, automatonFallback, drawUnderSlowDeath, pathToward, type AutomatonStyle, type AutomatonTier } from "../src/automaton"; import { automatonCommand, automatonFallback, drawUnderSlowDeath, interceptCell, pathToward, type AutomatonStyle, type AutomatonTier } from "../src/automaton";
import { pushSustained } from "./helpers"; import { pushSustained } from "./helpers";
/** Whose input does the maze want right now? */ /** Whose input does the maze want right now? */
@@ -1191,3 +1191,85 @@ describe("the clockwork under a curse, and before a pit", () => {
throw new Error("no straight three-square run on this board"); 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 bot = state.players.find((p) => p.id === "bot")!;
const foe = state.players.find((p) => p.id === "foe")!;
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 — the chase
// rule, not the interception, decides; with no attack in hand the
// hunter goes for gold as before.
const plain = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
expect(plain?.type).toBe("move");
});
});