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)}`);