diff --git a/packages/engine/src/automaton.ts b/packages/engine/src/automaton.ts index 72264d4..d626baa 100644 --- a/packages/engine/src/automaton.ts +++ b/packages/engine/src/automaton.ts @@ -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 diff --git a/packages/engine/test/automaton.test.ts b/packages/engine/test/automaton.test.ts index 2448449..3554d10 100644 --- a/packages/engine/test/automaton.test.ts +++ b/packages/engine/test/automaton.test.ts @@ -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"); + }); +});