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
+89 -38
View File
@@ -645,6 +645,15 @@ function bushOrMist(view: GameView, id: PlayerId): boolean {
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. */
function thiefOfMine(view: GameView) {
const carriers = new Set(
@@ -749,19 +758,24 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma
? Math.max(2, stack.numberValue ?? 2)
: (atk ? atk.base + (atk.perNumber ? stack.numberValue ?? 1 : 0) : 2)
: 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") {
// REVERSE eats points; a pure duration offers it nothing.
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.
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");
if (shield && (incoming >= 3 - flinch || (affliction && style === "worrier"))) {
if (shield && (weight >= 3 - flinch || (affliction && style === "worrier"))) {
return { type: "counteract", instanceId: shield.instanceId };
}
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.
if (affliction) {
const cleanse = find("remove-curse");
@@ -777,9 +791,11 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma
const permanentCurse = affliction &&
(attackId === "slow-death" || attackId === "walking-dead" || attackId === "idiot");
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");
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
// damage-less curse gives EMPATHY nothing to mirror.
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.
const tp = find("teleport");
if (tp && incoming >= 4 - flinch) {
if (tp && weight >= 4 - flinch) {
const out = escapeCell(view, me(view).position);
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.
const vanish = find("invisible");
if (vanish && incoming >= 3 - flinch) {
if (vanish && weight >= 3 - flinch) {
const num = numbersInHand(view)[0];
return {
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 together = cellKey(target.position) === cellKey(me(view).position);
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) {
const atk = ATTACKS[c.cardId];
if (!atk) continue;
@@ -831,25 +861,27 @@ function bestAttack(view: GameView, targetId: PlayerId, tier: TierTraits): { cmd
const isWand = c.cardId === "blaster-wand";
const uncharged = isWand && view.wandCharges[c.instanceId] == null;
if ((uncharged || atk.needsNumber) && !biggest) continue;
const withNumber = (atk.perNumber || uncharged) && biggest;
let damage = atk.base + (atk.perNumber && withNumber ? biggestValue : 0);
if (damage <= 0) continue;
// AMPLIFY doubles the heavy hitters (spells only, not thrown things).
const amplified = amplify && damage >= 4 && !isWand &&
c.cardId !== "dagger" && c.cardId !== "large-rock";
if (amplified) damage *= 2;
if (!best || damage > best.damage) {
best = {
damage,
cmd: {
const mustNumber = uncharged || atk.needsNumber === true;
const canAmplify = amplify && !isWand && c.cardId !== "dagger" && c.cardId !== "large-rock";
const numberChoices: (CardInstance | undefined)[] = atk.perNumber
? [undefined, ...numbers]
: [mustNumber ? biggest : undefined];
for (const num of numberChoices) {
if (mustNumber && !num) continue;
const value = num ? (cardDef(num.cardId).value ?? 0) : 0;
const base = atk.base + (atk.perNumber && num ? value : 0);
if (base <= 0) continue;
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,
target: { kind: "player", playerId: targetId },
// An illusion needs a spell to imitate; a fireball sells best.
...(c.cardId === "illusionary-attack" ? { params: { cardId: "fireball" } } : {}),
...(withNumber ? { numberInstanceIds: [biggest.instanceId] } : {}),
...(amplified ? { amplifyInstanceIds: [amplify.instanceId] } : {}),
},
};
...(num ? { numberInstanceIds: [num.instanceId] } : {}),
...(amp && amplify ? { amplifyInstanceIds: [amplify.instanceId] } : {}),
});
}
}
}
// 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) {
const shown = target.displayed.filter((c) => STONES.has(c.cardId)).length;
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 = {
damage: dmg,
damage: dmg, kill: dmg >= target.life, cost: biggestValue,
cmd: {
type: "cast", instanceId: sd.instanceId,
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.
@@ -957,6 +991,14 @@ function selfCare(view: GameView, style: AutomatonStyle, tier: TierTraits): Comm
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
// A curse on the clockwork gets scrubbed off.
const cursed = view.sustained.some(
@@ -1200,13 +1242,16 @@ export function automatonCommand(
const target = thief && visible.some((p) => p.id === thief.id)
? thief
: visible.sort((a, b) => a.life - b.life)[0]!;
// DROP OBJECT shakes my treasure out of the thief's hands.
if (thief && target.id === thief.id) {
const dob = inHand(view, "drop-object");
if (dob) {
// DROP OBJECT shakes the treasure out of the hands that matter:
// the thief of my gold, or any carrier whose delivery wins.
{
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 {
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) {
const carrier = visible.find((p) => {
if (thief && p.id === thief.id) return true;
if (deliveryWins(view, p)) return true;
if (!p.carriedTreasureId) return false;
return Math.abs(p.position.x - p.home.x) + Math.abs(p.position.y - p.home.y) <= 6;
});
@@ -1475,12 +1521,17 @@ export function automatonCommand(
// The war chest: when an attack in hand wants a number, the
// biggest one is reserved — a waterbolt unfired outbids a longer
// march, and walking is free next turn.
const wantsNumber = view.yourHand.some((c) => {
const atk = ATTACKS[c.cardId];
if (!atk) return false;
return atk.perNumber || atk.needsNumber === true ||
(c.cardId === "blaster-wand" && view.wandCharges[c.instanceId] == null);
});
// — 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];
if (!atk) return false;
return atk.perNumber || atk.needsNumber === true ||
(c.cardId === "blaster-wand" && view.wandCharges[c.instanceId] == null);
});
const spendable = wantsNumber ? numbers.slice(0, -1) : numbers;
// A treasure within free walking ends this turn at the grab (the
// gold-underfoot rung outranks everything) — a number played first