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:
co-authored by
Claude Fable 5
parent
324853ce51
commit
df2913f366
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user