diff --git a/packages/engine/src/automaton.ts b/packages/engine/src/automaton.ts index 64fafcd..08ab9d3 100644 --- a/packages/engine/src/automaton.ts +++ b/packages/engine/src/automaton.ts @@ -108,6 +108,8 @@ const USEFUL_NEUTRALS = new Set([ "fill-square-with-slime", "killer-ooze", "handful-of-tacks", "dust-cloud", // bursts and breathing room "mad-dash", "power-run", "add", "around-the-corner", "fear", "ugly", "buddy", + // finishers and boosts + "adrenaline", "strength", "extend", "mega-monster", "warp-wand", ]); function me(view: GameView) { @@ -137,7 +139,7 @@ function discardValue(c: CardInstance, view: GameView, style?: AutomatonStyle): if (def.cardType === "counteraction" || def.cardType === "neutral/counteraction") return 9; if (SUMMONS.has(c.cardId) || UNLOCKS.includes(c.cardId) || STONES.has(c.cardId)) return 8; if (ATTACKS[c.cardId] != null || AFFLICTIONS[c.cardId] != null || - c.cardId === "stone-dead") return 7; + c.cardId === "stone-dead" || c.cardId === "teleport-opponent") return 7; if (USEFUL_NEUTRALS.has(c.cardId)) return 6; if (c.cardId === "lifesaver" && view.players.length > 2) return 6; if (c.cardId === "big-man" && style === "berserker") return 6; @@ -398,14 +400,21 @@ function pathDenial(view: GameView): Command | null { */ function roadworkPlan( view: GameView, self: { position: Cell }, goals: Set, - canUnlock: boolean, normalDist: number, + canUnlock: boolean, normalDist: number, movesLeft: number, ): Command | null { if (goals.size === 0) return null; const dispel = inHand(view, "dispel-creation"); const s2w = inHand(view, "stone-to-water"); const cdoor = canUnlock ? inHand(view, "create-door") : undefined; const dwarp = inHand(view, "dimensional-warp"); - if (!dispel && !s2w && !cdoor && !dwarp) return null; + // WARP WAND holds a wall open only until the turn ends — the crossing + // must be reachable on this turn's legs, and the wand wants a charge. + const wandCard = inHand(view, "warp-wand"); + const wandNumber = numbersInHand(view)[0]; + const wand = wandCard && !view.turn.wandsUsed.includes(wandCard.instanceId) && + (view.wandCharges[wandCard.instanceId] != null || wandNumber) + ? wandCard : undefined; + if (!dispel && !s2w && !cdoor && !dwarp && !wand) return null; const sighted = sightedCellsFor(view); const dHere = distancesFrom(view, [self.position], canUnlock); @@ -432,16 +441,27 @@ function roadworkPlan( if (!view.board.cells[cellKey(beyond)]) continue; if (!sighted.has(cellKey(cell)) && !sighted.has(cellKey(beyond))) continue; let total = Infinity; + let nearSteps = Infinity; for (const [a, b] of [[cell, beyond], [beyond, cell]] as [Cell, Cell][]) { const da = dHere.get(cellKey(a)); const db = dGoal.get(cellKey(b)); - if (da !== undefined && db !== undefined) total = Math.min(total, da + 1 + db); + if (da !== undefined && db !== undefined && da + 1 + db < total) { + total = da + 1 + db; + nearSteps = da; + } } if (total === Infinity) continue; const target = { kind: "edge" as const, cell, side }; if (dispel && created.has(key)) { offer({ type: "cast", instanceId: dispel.instanceId, target }, total, 4); } + if (wand && state === "wall" && nearSteps + 1 <= movesLeft) { + const uncharged = view.wandCharges[wand.instanceId] == null; + offer({ + type: "cast", instanceId: wand.instanceId, target, + ...(uncharged && wandNumber ? { numberInstanceIds: [wandNumber.instanceId] } : {}), + }, total, 4); + } if (s2w && state === "wall") { // The collapsing wave covers two cells each side of the wall — melt // it only from outside its reach. @@ -832,10 +852,12 @@ function bestAttack(view: GameView, targetId: PlayerId, tier: TierTraits): Comma return best?.cmd ?? null; } -/** An affliction worth casting when no damage lands, mid number attached. */ +/** An affliction worth casting when no damage lands, mid number attached. + * EXTEND doubles the misery's stay when the hand can spare it. */ function bestAffliction(view: GameView, targetId: PlayerId, isThief: boolean): Command | null { const numbers = numbersInHand(view); const mid = numbers[Math.floor(numbers.length / 2)]; + const extend = inHand(view, "extend"); for (const c of view.yourHand) { const aff = AFFLICTIONS[c.cardId]; if (!aff) continue; @@ -845,11 +867,41 @@ function bestAffliction(view: GameView, targetId: PlayerId, isThief: boolean): C type: "cast", instanceId: c.instanceId, target: { kind: "player", playerId: targetId }, ...(aff.withNumber && mid ? { numberInstanceIds: [mid.instanceId] } : {}), + ...(aff.withNumber && mid && extend ? { extendInstanceId: extend.instanceId } : {}), }; } return null; } +/** + * The cruelest legal square for TELEPORT OPPONENT: farthest from the + * victim's home by walking distance — a sealed pocket, if one exists. The + * destination needs no line of sight, so the whole maze is on the table. + */ +function exileCell(view: GameView, victim: { home: Cell; position: Cell }): Cell | null { + const dHome = distancesFrom(view, [victim.home], true); + const self = me(view); + const UNREACHABLE = 1000; + let best: { cell: Cell; score: number; tie: number } | null = null; + for (const k of Object.keys(view.board.cells)) { + if (view.squareContents[k]?.kind === "stone") continue; + const [x, y] = k.split(",").map(Number) as [number, number]; + if (view.board.homes.some((h) => h.x === x && h.y === y)) continue; + if (view.treasures.some((t) => t.position && cellKey(t.position) === k)) continue; + if (view.players.some((p) => p.alive && cellKey(p.position) === k)) continue; + const d = dHome.get(k); + const score = d === undefined ? UNREACHABLE : d; + const tie = Math.abs(x - self.position.x) + Math.abs(y - self.position.y); + if (!best || score > best.score || (score === best.score && tie > best.tie)) { + best = { cell: { x, y }, score, tie }; + } + } + // Only worth the turn's attack if it truly worsens their day. + const now = dHome.get(cellKey(victim.position)) ?? 0; + if (best && (best.score >= UNREACHABLE || best.score >= now + 5)) return best.cell; + return null; +} + /** An empty, sighted square near the target for a summoned creature. */ function summonSpot(view: GameView, near: Cell): Cell | null { const sighted = sightedCellsFor(view); @@ -1069,6 +1121,23 @@ export function automatonCommand( const care = selfCare(view, style, tier); if (care) return care; + // MEGA-MONSTER: a doubled stride turns a pet into a bloodhound. + if (tier.buffs && livingEnemies(view).length > 0) { + const mm = inHand(view, "mega-monster"); + if (mm) { + const sighted = sightedCellsFor(view); + const pet = view.creatures.find((c) => + c.controllerId === you && c.kind !== "shadow" && c.kind !== "alter-ego" && + c.movesPerTurn <= 3 && sighted.has(cellKey(c.position))); + if (pet) { + return { + type: "cast", instanceId: mm.instanceId, + target: { kind: "creature", creatureId: pet.id }, params: { boost: "movement" }, + }; + } + } + } + // Command the menagerie: creatures march and maul before the wizard moves. for (const c of view.creatures) { if (c.controllerId !== you || c.justCreated) continue; @@ -1087,7 +1156,10 @@ export function automatonCommand( const thief = thiefOfMine(view); // One attack per turn: the thief of my gold dies first, then the weakest. - if (!view.turn.attackUsed && view.turn.round > 1) { + // ADRENALINE stretches that to two while it lasts. + const adrenalized = view.sustained.some((s) => s.cardId === "adrenaline" && s.targetId === you); + if ((!view.turn.attackUsed || (adrenalized && !view.turn.secondAttackUsed)) && + view.turn.round > 1) { const sighted = sightedCellsFor(view); const visible = livingEnemies(view).filter((p) => sighted.has(cellKey(p.position))); if (visible.length > 0) { @@ -1104,6 +1176,48 @@ export function automatonCommand( }; } } + // TELEPORT OPPONENT exiles a delivery in progress: the thief of my + // gold, or any carrier closing on their home. + const exile = inHand(view, "teleport-opponent"); + if (exile) { + const carrier = visible.find((p) => { + if (thief && p.id === thief.id) 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; + }); + const dest = carrier ? exileCell(view, carrier) : null; + if (carrier && dest) { + return { + type: "cast", instanceId: exile.instanceId, + target: { kind: "player", playerId: carrier.id }, params: { cell: dest }, + }; + } + } + if (tier.buffs && !view.turn.attackUsed) { + const smallest = numbersInHand(view)[0]; + // ADRENALINE: when no single blow finishes the target but two would. + const adr = adrenalized ? undefined : inHand(view, "adrenaline"); + if (adr && smallest) { + const bases = view.yourHand + .filter((c) => ATTACKS[c.cardId] && !ATTACKS[c.cardId]!.needsNumber && c.cardId !== "heave-ho") + .map((c) => ATTACKS[c.cardId]!.base) + .sort((a, b) => b - a); + if (bases.length >= 2 && bases[0]! < target.life && bases[0]! + bases[1]! >= target.life) { + return { type: "cast", instanceId: adr.instanceId, numberInstanceIds: [smallest.instanceId] }; + } + } + // STRENGTH: doubled steel where it turns a wound into a finisher. + const strengthUp = view.sustained.some((s) => s.cardId === "strength" && s.targetId === you); + const str = strengthUp ? undefined : inHand(view, "strength"); + if (str && smallest) { + const physBase = Math.max(0, ...view.yourHand + .filter((c) => c.cardId === "dagger" || c.cardId === "large-rock") + .map((c) => ATTACKS[c.cardId]!.base)); + if (physBase > 0 && physBase < target.life && physBase * 2 >= target.life) { + return { type: "cast", instanceId: str.instanceId, numberInstanceIds: [smallest.instanceId] }; + } + } + } const spell = bestAttack(view, target.id, tier); if (spell) return spell; const misery = tier.afflictions ? bestAffliction(view, target.id, thief?.id === target.id) : null; @@ -1182,9 +1296,10 @@ export function automatonCommand( return { type: "cast", instanceId: ptw.instanceId }; } } - // The rest of the toolbox: dispel, melt, door-and-key, warp tokens. + // The rest of the toolbox: dispel, melt, door-and-key, wand, warp tokens. if (!view.turn.actionsEnded) { - const work = roadworkPlan(view, self, objectives, canUnlock, path?.distance ?? Infinity); + const work = roadworkPlan(view, self, objectives, canUnlock, + path?.distance ?? Infinity, view.turn.movementAllowance - view.turn.movementUsed); if (work) return work; } // Standing on a warp token whose far side is closer to the goal: step in. diff --git a/packages/engine/test/automaton.test.ts b/packages/engine/test/automaton.test.ts index c4b4599..9c66885 100644 --- a/packages/engine/test/automaton.test.ts +++ b/packages/engine/test/automaton.test.ts @@ -374,3 +374,56 @@ describe("the widened spellbook", () => { if (!r.ok) throw new Error(r.error); }); }); + +describe("round two of the owner's tactics", () => { + it("exiles a thief carrying its gold to the far end of nowhere", () => { + let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"], deckRev: 24 }); + // burn round 1 and reach the bot's turn + for (let guard = 0; guard < 10 && !(actingSeat(state) === "bot" && state.turn.round > 1); guard++) { + const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 }); + if (!r.ok) throw new Error(r.error); + state = r.state; + } + const bot = state.players.find((p) => p.id === "bot")!; + const thief = state.players.find((p) => p.id === "other")!; + const t = state.treasures.find((t) => t.owner === "bot")!; + t.position = null; + t.carriedBy = "other"; + thief.carriedTreasureId = t.id; + // The thief is a step from delivering; the bot watches from beside them. + thief.position = { x: thief.home.x, y: thief.home.y === 0 ? 1 : thief.home.y - 1 }; + bot.position = { ...thief.position }; + bot.hand = [{ instanceId: "teleport-opponent#T", cardId: "teleport-opponent" }]; + const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage"); + expect(cmd).toMatchObject({ type: "cast", instanceId: "teleport-opponent#T" }); + const dest = (cmd as { params: { cell: { x: number; y: number } } }).params.cell; + // The chosen square is a long march from the thief's own home. + const d = Math.abs(dest.x - thief.home.x) + Math.abs(dest.y - thief.home.y); + expect(d).toBeGreaterThanOrEqual(5); + const r = applyCommand(state, "bot", cmd!); + if (!r.ok) throw new Error(r.error); + }); + + it("casts adrenaline when two blows finish what one cannot", () => { + let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"], deckRev: 24 }); + for (let guard = 0; guard < 10 && !(actingSeat(state) === "bot" && state.turn.round > 1); guard++) { + const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 }); + if (!r.ok) throw new Error(r.error); + state = r.state; + } + const bot = state.players.find((p) => p.id === "bot")!; + const prey = state.players.find((p) => p.id === "other")!; + prey.position = { ...bot.position }; + prey.life = 7; // fireball (5) alone cannot; fireball + dagger (3) can + bot.hand = [ + { instanceId: "adrenaline#T", cardId: "adrenaline" }, + { instanceId: "fireball#T", cardId: "fireball" }, + { instanceId: "dagger#T", cardId: "dagger" }, + { instanceId: "number-2#T", cardId: "number-2" }, + ]; + const cmd = automatonCommand(viewFor(state, "bot"), "berserker", "archmage"); + expect(cmd).toMatchObject({ type: "cast", instanceId: "adrenaline#T" }); + const r = applyCommand(state, "bot", cmd!); + if (!r.ok) throw new Error(r.error); + }); +});