// 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 [seeds=30] [--table new,old,old,old] [--style hunter] [--basic] // // Make the old checkout with `git worktree add `. 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)}`);