The archmage learns the lessons humans taught it

Eric beats the clockwork four games in five; these close the holes he
walks through. The treasure race: no number card is hoarded for a war
chest when no enemy stands within six squares, and never against the
delivery that wins the game outright. The kill: bestAttack now shops
every number and the amplify separately, preferring a killing blow at
minimal spend (with three points of soak margin, or an ABSORB undoes
it) over maximum splash, banking the big numbers for the next fight.
The last stand: a blow that would be lethal escalates past every
thrift threshold — any counter that can save the clockwork is cheap
at that price, ABSORB's three soaked points included when they are
exactly the margin. The race denial: TELEPORT OPPONENT and DROP
OBJECT now fire on any carrier one delivery from winning, not just
the thief of the bot's own gold. And the MASTER KEY goes on display
the turn it is drawn, opening every workable lock on the march for
free. A/B against the prior brain across 300 mirror games: parity
(the features aim at human play patterns mirrors rarely exercise);
three scenario tests pin the new instincts. Ships ungated — brains
choose commands, ledgers record them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
This commit is contained in:
Eric Wagoner
2026-08-25 22:30:03 -04:00
co-authored by Claude Fable 5
parent 324853ce51
commit df2913f366
2 changed files with 170 additions and 38 deletions
+84 -33
View File
@@ -645,6 +645,15 @@ function bushOrMist(view: GameView, id: PlayerId): boolean {
view.sustained.some((s) => s.cardId === "mist-body" && s.targetId === id); view.sustained.some((s) => s.cardId === "mist-body" && s.targetId === id);
} }
/** Would this wizard's delivery, landed, win them the game right now? */
function deliveryWins(view: GameView, p: { id: PlayerId; home: Cell; carriedTreasureId: string | null }): boolean {
if (!p.carriedTreasureId) return false;
const carried = view.treasures.find((t) => t.id === p.carriedTreasureId);
if (!carried || carried.owner === p.id) return false;
return view.treasures.some(
(t) => t.owner !== p.id && t.position && cellKey(t.position) === cellKey(p.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(
@@ -749,19 +758,24 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma
? Math.max(2, stack.numberValue ?? 2) ? Math.max(2, stack.numberValue ?? 2)
: (atk ? atk.base + (atk.perNumber ? stack.numberValue ?? 1 : 0) : 2) : (atk ? atk.base + (atk.perNumber ? stack.numberValue ?? 1 : 0) : 2)
: 1; : 1;
// A blow that would be the last one outranks every thrift threshold:
// when the incoming points reach the clockwork's life, any counter
// that can save it is cheap at the price.
const lethal = !affliction && incoming >= me(view).life;
const weight = lethal ? Math.max(incoming, 9) : incoming;
if (stack.kind === "spell") { if (stack.kind === "spell") {
// REVERSE eats points; a pure duration offers it nothing. // REVERSE eats points; a pure duration offers it nothing.
const reverse = find("reverse"); const reverse = find("reverse");
if (reverse && !affliction && incoming >= 4 - flinch) return { type: "counteract", instanceId: reverse.instanceId }; if (reverse && !affliction && weight >= 4 - flinch) return { type: "counteract", instanceId: reverse.instanceId };
// ABSORB SPELL steals the good ones for later. // ABSORB SPELL steals the good ones for later.
const absorbSpell = find("absorb-spell"); const absorbSpell = find("absorb-spell");
if (absorbSpell && incoming >= 3) return { type: "counteract", instanceId: absorbSpell.instanceId }; if (absorbSpell && weight >= 3) return { type: "counteract", instanceId: absorbSpell.instanceId };
const shield = find("full-shield"); const shield = find("full-shield");
if (shield && (incoming >= 3 - flinch || (affliction && style === "worrier"))) { if (shield && (weight >= 3 - flinch || (affliction && style === "worrier"))) {
return { type: "counteract", instanceId: shield.instanceId }; return { type: "counteract", instanceId: shield.instanceId };
} }
const reflect = find("full-reflection"); const reflect = find("full-reflection");
if (reflect && incoming >= 4 - flinch) return { type: "counteract", instanceId: reflect.instanceId }; if (reflect && weight >= 4 - flinch) return { type: "counteract", instanceId: reflect.instanceId };
// A curse in flight is best refused at the door. // A curse in flight is best refused at the door.
if (affliction) { if (affliction) {
const cleanse = find("remove-curse"); const cleanse = find("remove-curse");
@@ -777,9 +791,11 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma
const permanentCurse = affliction && const permanentCurse = affliction &&
(attackId === "slow-death" || attackId === "walking-dead" || attackId === "idiot"); (attackId === "slow-death" || attackId === "walking-dead" || attackId === "idiot");
const absorb = find("absorb"); const absorb = find("absorb");
if (absorb && !affliction && incoming >= 2 - flinch && incoming <= 3) return { type: "counteract", instanceId: absorb.instanceId }; if (absorb && !affliction && weight >= 2 - flinch && (incoming <= 3 || (lethal && incoming - 3 < me(view).life))) {
return { type: "counteract", instanceId: absorb.instanceId };
}
const blunt = find("blunt"); const blunt = find("blunt");
if (blunt && !permanentCurse && incoming >= 2 - flinch) return { type: "counteract", instanceId: blunt.instanceId }; if (blunt && !permanentCurse && weight >= 2 - flinch) return { type: "counteract", instanceId: blunt.instanceId };
// The berserker shares its pain out of spite — but only pain: a // The berserker shares its pain out of spite — but only pain: a
// damage-less curse gives EMPATHY nothing to mirror. // damage-less curse gives EMPATHY nothing to mirror.
const empathy = find("empathy"); const empathy = find("empathy");
@@ -788,13 +804,13 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma
} }
// Nothing to block with: teleport clear of the big ones. // Nothing to block with: teleport clear of the big ones.
const tp = find("teleport"); const tp = find("teleport");
if (tp && incoming >= 4 - flinch) { if (tp && weight >= 4 - flinch) {
const out = escapeCell(view, me(view).position); const out = escapeCell(view, me(view).position);
if (out) return { type: "counteract", instanceId: tp.instanceId, params: { cell: out } }; if (out) return { type: "counteract", instanceId: tp.instanceId, params: { cell: out } };
} }
// Or vanish: INVISIBLE gives the attacker a 1-in-4 hit and lingers after. // Or vanish: INVISIBLE gives the attacker a 1-in-4 hit and lingers after.
const vanish = find("invisible"); const vanish = find("invisible");
if (vanish && incoming >= 3 - flinch) { if (vanish && weight >= 3 - flinch) {
const num = numbersInHand(view)[0]; const num = numbersInHand(view)[0];
return { return {
type: "counteract", instanceId: vanish.instanceId, type: "counteract", instanceId: vanish.instanceId,
@@ -819,7 +835,21 @@ function bestAttack(view: GameView, targetId: PlayerId, tier: TierTraits): { cmd
const target = view.players.find((p) => p.id === targetId)!; const target = view.players.find((p) => p.id === targetId)!;
const together = cellKey(target.position) === cellKey(me(view).position); const together = cellKey(target.position) === cellKey(me(view).position);
const amplify = tier.amplify ? inHand(view, "amplify") : undefined; const amplify = tier.amplify ? inHand(view, "amplify") : undefined;
let best: { cmd: Command; damage: number } | null = null; // A killing blow at minimal spend outranks maximum splash — but only
// when it clears the target's life with soak to spare: an exact-lethal
// blow dies to one ABSORB, so thrift begins three points past the kill.
type BestPick = { cmd: Command; damage: number; kill: boolean; cost: number };
let best: BestPick | null = null;
const offer = (damage: number, cost: number, cmd: Command) => {
const rank = (d: number) => (d >= target.life + 3 ? 2 : d >= target.life ? 1 : 0);
const r = rank(damage);
const better = !best ? true
: r !== rank(best.damage) ? r > rank(best.damage)
: r === 2 ? cost < best.cost
: damage !== best.damage ? damage > best.damage
: cost < best.cost;
if (better) best = { cmd, damage, kill: damage >= target.life, cost };
};
for (const c of view.yourHand) { for (const c of view.yourHand) {
const atk = ATTACKS[c.cardId]; const atk = ATTACKS[c.cardId];
if (!atk) continue; if (!atk) continue;
@@ -831,25 +861,27 @@ function bestAttack(view: GameView, targetId: PlayerId, tier: TierTraits): { cmd
const isWand = c.cardId === "blaster-wand"; const isWand = c.cardId === "blaster-wand";
const uncharged = isWand && view.wandCharges[c.instanceId] == null; const uncharged = isWand && view.wandCharges[c.instanceId] == null;
if ((uncharged || atk.needsNumber) && !biggest) continue; if ((uncharged || atk.needsNumber) && !biggest) continue;
const withNumber = (atk.perNumber || uncharged) && biggest; const mustNumber = uncharged || atk.needsNumber === true;
let damage = atk.base + (atk.perNumber && withNumber ? biggestValue : 0); const canAmplify = amplify && !isWand && c.cardId !== "dagger" && c.cardId !== "large-rock";
if (damage <= 0) continue; const numberChoices: (CardInstance | undefined)[] = atk.perNumber
// AMPLIFY doubles the heavy hitters (spells only, not thrown things). ? [undefined, ...numbers]
const amplified = amplify && damage >= 4 && !isWand && : [mustNumber ? biggest : undefined];
c.cardId !== "dagger" && c.cardId !== "large-rock"; for (const num of numberChoices) {
if (amplified) damage *= 2; if (mustNumber && !num) continue;
if (!best || damage > best.damage) { const value = num ? (cardDef(num.cardId).value ?? 0) : 0;
best = { const base = atk.base + (atk.perNumber && num ? value : 0);
damage, if (base <= 0) continue;
cmd: { for (const amp of canAmplify && base >= 4 ? [false, true] : [false]) {
const damage = amp ? base * 2 : base;
offer(damage, value + (amp ? 5 : 0), {
type: "cast", instanceId: c.instanceId, type: "cast", instanceId: c.instanceId,
target: { kind: "player", playerId: targetId }, target: { kind: "player", playerId: targetId },
// An illusion needs a spell to imitate; a fireball sells best. // An illusion needs a spell to imitate; a fireball sells best.
...(c.cardId === "illusionary-attack" ? { params: { cardId: "fireball" } } : {}), ...(c.cardId === "illusionary-attack" ? { params: { cardId: "fireball" } } : {}),
...(withNumber ? { numberInstanceIds: [biggest.instanceId] } : {}), ...(num ? { numberInstanceIds: [num.instanceId] } : {}),
...(amplified ? { amplifyInstanceIds: [amplify.instanceId] } : {}), ...(amp && amplify ? { amplifyInstanceIds: [amplify.instanceId] } : {}),
}, });
}; }
} }
} }
// STONE DEAD: number times the stones the victim carries — the displayed // STONE DEAD: number times the stones the victim carries — the displayed
@@ -858,9 +890,10 @@ function bestAttack(view: GameView, targetId: PlayerId, tier: TierTraits): { cmd
if (sd && biggest) { if (sd && biggest) {
const shown = target.displayed.filter((c) => STONES.has(c.cardId)).length; const shown = target.displayed.filter((c) => STONES.has(c.cardId)).length;
const dmg = biggestValue * shown; const dmg = biggestValue * shown;
if (dmg >= 4 && dmg > (best?.damage ?? 0)) { const cur = best as BestPick | null;
if (dmg >= 4 && (!cur || (dmg >= target.life && !cur.kill) || dmg > cur.damage)) {
best = { best = {
damage: dmg, damage: dmg, kill: dmg >= target.life, cost: biggestValue,
cmd: { cmd: {
type: "cast", instanceId: sd.instanceId, type: "cast", instanceId: sd.instanceId,
target: { kind: "player", playerId: targetId }, target: { kind: "player", playerId: targetId },
@@ -869,7 +902,8 @@ function bestAttack(view: GameView, targetId: PlayerId, tier: TierTraits): { cmd
}; };
} }
} }
return best; const picked = best as BestPick | null;
return picked ? { cmd: picked.cmd, damage: picked.damage } : null;
} }
/** An affliction worth casting when no damage lands, mid number attached. /** An affliction worth casting when no damage lands, mid number attached.
@@ -957,6 +991,14 @@ function selfCare(view: GameView, style: AutomatonStyle, tier: TierTraits): Comm
return { type: "cast", instanceId: c.instanceId }; return { type: "cast", instanceId: c.instanceId };
} }
} }
// So does the MASTER KEY: displayed, every workable lock in the maze
// opens under its bearer's hand without another cast.
{
const mk = inHand(view, "master-key");
if (mk && !self.displayed.some((d) => d.instanceId === mk.instanceId)) {
return { type: "cast", instanceId: mk.instanceId };
}
}
if (!tier.buffs) return null; // the apprentice's book ends at the stones if (!tier.buffs) return null; // the apprentice's book ends at the stones
// A curse on the clockwork gets scrubbed off. // A curse on the clockwork gets scrubbed off.
const cursed = view.sustained.some( const cursed = view.sustained.some(
@@ -1200,13 +1242,16 @@ export function automatonCommand(
const target = thief && visible.some((p) => p.id === thief.id) const target = thief && visible.some((p) => p.id === thief.id)
? thief ? thief
: visible.sort((a, b) => a.life - b.life)[0]!; : visible.sort((a, b) => a.life - b.life)[0]!;
// DROP OBJECT shakes my treasure out of the thief's hands. // DROP OBJECT shakes the treasure out of the hands that matter:
if (thief && target.id === thief.id) { // the thief of my gold, or any carrier whose delivery wins.
const dob = inHand(view, "drop-object"); {
if (dob) { const mark = (thief && visible.some((p) => p.id === thief.id)) ? thief
: visible.find((p) => deliveryWins(view, p));
const dob = mark ? inHand(view, "drop-object") : undefined;
if (mark && dob) {
return { return {
type: "cast", instanceId: dob.instanceId, type: "cast", instanceId: dob.instanceId,
target: { kind: "player", playerId: thief.id }, params: { cardId: "treasure" }, target: { kind: "player", playerId: mark.id }, params: { cardId: "treasure" },
}; };
} }
} }
@@ -1216,6 +1261,7 @@ export function automatonCommand(
if (exile) { if (exile) {
const carrier = visible.find((p) => { const carrier = visible.find((p) => {
if (thief && p.id === thief.id) return true; if (thief && p.id === thief.id) return true;
if (deliveryWins(view, p)) return true;
if (!p.carriedTreasureId) return false; if (!p.carriedTreasureId) return false;
return Math.abs(p.position.x - p.home.x) + Math.abs(p.position.y - p.home.y) <= 6; return Math.abs(p.position.x - p.home.x) + Math.abs(p.position.y - p.home.y) <= 6;
}); });
@@ -1475,7 +1521,12 @@ export function automatonCommand(
// The war chest: when an attack in hand wants a number, the // The war chest: when an attack in hand wants a number, the
// biggest one is reserved — a waterbolt unfired outbids a longer // biggest one is reserved — a waterbolt unfired outbids a longer
// march, and walking is free next turn. // march, and walking is free next turn.
const wantsNumber = view.yourHand.some((c) => { // — but a chest hoarded with no enemy in reach just slows the
// march, and no reserve outbids the delivery that wins the game.
const foesClose = livingEnemies(view).some((p) =>
Math.abs(p.position.x - self.position.x) + Math.abs(p.position.y - self.position.y) <= 6);
const wantsNumber = foesClose && !deliveryWins(view, self) &&
view.yourHand.some((c) => {
const atk = ATTACKS[c.cardId]; const atk = ATTACKS[c.cardId];
if (!atk) return false; if (!atk) return false;
return atk.perNumber || atk.needsNumber === true || return atk.perNumber || atk.needsNumber === true ||
+81
View File
@@ -775,3 +775,84 @@ describe("the thief-chase holds one goal per turn", () => {
} }
}); });
}); });
describe("the archmage's sharpened instincts", () => {
function botTurn(seed = 42) {
let { state } = createGame({ playerIds: ["foe", "bot"], seed, sets: ["basic", "expansion1"] });
while (state.turn.round < 2 || state.players[state.turn.activeIndex]!.id !== "bot") {
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(r.error);
state = r.state;
}
return state;
}
it("a MASTER KEY drawn goes straight on display", () => {
const state = botTurn();
const bot = state.players.find((p) => p.id === "bot")!;
bot.hand.push({ cardId: "master-key", instanceId: "MK" } as never);
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
expect(cmd).toEqual({ type: "cast", instanceId: "MK" });
});
it("a lethal small blow is countered, thrift be damned", () => {
let { state } = createGame({ playerIds: ["foe", "bot"], seed: 42, sets: ["basic", "expansion1"] });
while (state.turn.round < 2 || state.players[state.turn.activeIndex]!.id !== "foe") {
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(r.error);
state = r.state;
}
const foe = state.players.find((p) => p.id === "foe")!;
const bot = state.players.find((p) => p.id === "bot")!;
bot.position = { ...foe.position };
bot.life = 2;
bot.hand = [{ cardId: "full-shield", instanceId: "FS" } as never];
foe.hand.push({ cardId: "fireball", instanceId: "FB" } as never);
const r = applyCommand(state, "foe", {
type: "cast", instanceId: "FB", target: { kind: "player", playerId: "bot" },
});
if (!r.ok) throw new Error(r.error);
// Base fireball, 2 points: below every thrift threshold, but fatal at
// 2 life — the shield comes out.
const cmd = automatonCommand(viewFor(r.state, "bot"), "hunter", "archmage");
expect(cmd).toEqual({ type: "counteract", instanceId: "FS" });
});
it("no number is hoarded against the delivery that wins the game", () => {
let state = botTurn();
const bot = state.players.find((p) => p.id === "bot")!;
const foe = state.players.find((p) => p.id === "foe")!;
// One of the foe's treasures already rests at the bot's home; the bot
// carries the second, four squares out with three legs — only the
// number bridges it home this turn.
const gold = state.treasures.filter((t) => t.owner === "foe");
expect(gold.length).toBeGreaterThanOrEqual(2);
gold[0]!.position = { ...bot.home };
gold[0]!.carriedBy = null;
gold[1]!.carriedBy = "bot";
gold[1]!.position = null;
bot.carriedTreasureId = gold[1]!.id;
foe.position = { ...bot.home }; // a foe in sight: the war chest would hoard
bot.hand = [
{ cardId: "fireball", instanceId: "FB" } as never,
{ cardId: "number-3", instanceId: "N3" } as never,
];
// Stand the bot a straight, open four squares from home.
const home = bot.home;
const column = [0, 1, 2, 3, 4].map((d) => ({ x: home.x, y: home.y + d }));
if (!column.every((c) => viewFor(state, "bot").board.cells[cellKey(c)])) return;
bot.position = { ...column[4]! };
for (const c of column.slice(0, 4)) state.edgeOverrides[edgeKey(c, "S")] = "open";
for (let guard = 0; guard < 12; guard++) {
const view = viewFor(state, "bot");
const cmd = automatonCommand(view, "hunter", "archmage") ?? automatonFallback(view, "archmage");
if (cmd.type === "playNumberForMovement") return; // the chest opened for the win
if (cmd.type === "endTurn") break;
const r = applyCommand(state, "bot", cmd);
if (!r.ok) break;
state = r.state;
if (state.phase !== "playing") return; // delivered and won outright
}
throw new Error("the clockwork hoarded its number instead of winning");
});
});