diff --git a/deploy/brain-trial.mjs b/deploy/brain-trial.mjs new file mode 100644 index 0000000..9200517 --- /dev/null +++ b/deploy/brain-trial.mjs @@ -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 [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)}`); diff --git a/packages/engine/src/automaton.ts b/packages/engine/src/automaton.ts index e720035..ca5f365 100644 --- a/packages/engine/src/automaton.ts +++ b/packages/engine/src/automaton.ts @@ -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)); } +/** 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. */ function thiefOfMine(view: GameView) { const carriers = new Set( @@ -1455,9 +1501,22 @@ export function automatonCommand( .filter((p) => sighted.has(cellKey(p.position)) && !shieldedFromMe(p.id) && !myPact(p.id) && !bushOrMist(view, p.id)); if (visible.length > 0) { - const target = thief && visible.some((p) => p.id === thief.id) - ? thief - : visible.sort((a, b) => a.life - b.life)[0]!; + // The thief of my gold dies first, and a kill on the table is + // taken. Otherwise the aim shifts to whoever is nearest to winning: + // 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: // the thief of my gold, or any carrier whose delivery wins. { @@ -1698,15 +1757,25 @@ export function automatonCommand( livingEnemies(view).some((p) => !p.carriedTreasureId && 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 ? ownGoldCells : chasing ? new Set([cellKey(thief.position)]) - : bankThreatened - ? new Set([cellKey(self.home)]) - : style === "berserker" && !self.carriedTreasureId - ? (enemyCells.size > 0 ? enemyCells : gold) - : gold; + : cutoff + ? new Set([cellKey(cutoff)]) + : bankThreatened + ? new Set([cellKey(self.home)]) + : style === "berserker" && !self.carriedTreasureId + ? (enemyCells.size > 0 ? enemyCells : gold) + : gold; const path = pathToward(view, self.position, objectives, { avoidNearEnemies: style === "worrier" && !chasing, canUnlock }) ?? pathToward(view, self.position, objectives, { canUnlock }) ?? diff --git a/packages/engine/test/automaton.test.ts b/packages/engine/test/automaton.test.ts index caa35c6..f64aa34 100644 --- a/packages/engine/test/automaton.test.ts +++ b/packages/engine/test/automaton.test.ts @@ -8,7 +8,7 @@ import { } from "../src/game"; import { cellKey, edgeKey } from "../src/board"; 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"; /** 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"); }); }); + +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"); + }); +});