Since 6e49fc9. Engine: the teleport BFS in the view and the engine's
wallIgnoringDistance share teleportStep; the counter trail is weighed
between steps instead of on six arms; the bush attack takes its
adjacency from SIDES like the wall does; a dead redirected clause, a
one-member set, an identity ternary, and a stray try/catch are gone;
two tests now assert what their names promise. Server: KEEPER and
MAX_SEATS live in rooms.ts, the three Sentry bells ring through one
helper, the unread reply field is dropped, actingSeat comes from the
engine, and the protocol header names every message. Web: ReplayStep
is one type, cardName one helper, the say box and the reference section
one snippet each, the keeper's name arrives from the server rather than
the source, and the lifesaver ruling that ruled nothing is out.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
71 lines
3.5 KiB
JavaScript
71 lines
3.5 KiB
JavaScript
// 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 { actingSeat, 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 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)}`);
|