diff --git a/packages/engine/src/automaton.ts b/packages/engine/src/automaton.ts index 63e9f3a..ca2d7be 100644 --- a/packages/engine/src/automaton.ts +++ b/packages/engine/src/automaton.ts @@ -586,7 +586,7 @@ function pathToward( view: GameView, from: Cell, goals: Set, - opts: { avoidNearEnemies?: boolean; canUnlock?: boolean; throughHazards?: boolean } = {}, + opts: { avoidNearEnemies?: boolean; canUnlock?: boolean; throughHazards?: boolean; throughJails?: boolean } = {}, ): PathResult | null { if (goals.size === 0 || goals.has(cellKey(from))) return null; const enemyCells = livingEnemies(view).map((p) => p.position); @@ -611,9 +611,14 @@ function pathToward( cameBy.set(k, { prev: cellKey(c), dir, viaDoor }); if (goals.has(k)) { found = k; break; } // Hazards end a clean path but are waded through when asked to. + // THORNBUSH is never a wade: entering ends the turn, eats the NEXT + // turn, and stings — a jail with foliage. Only a wizard with no + // road at all (throughJails) dives in. "Few will dive into one + // unless they are desperate." const hazard = view.squareContents[k]?.kind; + if (hazard === "thornbush" && !opts.throughJails) continue; if (!opts.throughHazards && - (hazard === "pit" || hazard === "ooze" || hazard === "thornbush" || + (hazard === "pit" || hazard === "ooze" || hazard === "rosebush" || hazard === "slime" || hazard === "tacks")) continue; if (opts.avoidNearEnemies && nearEnemy(to)) continue; next.push(to); @@ -1100,6 +1105,19 @@ function selfCare(view: GameView, style: AutomatonStyle, tier: TierTraits): Comm // wizard hauling gold, anyone bleeding out. const pressed = enemyNear && (style === "worrier" || self.carriedTreasureId != null || self.life <= 5); + // MIST-BODY turns a pressed carrier into an untouchable delivery — no + // attack, drop, exile, or thief finds fog. Any temperament takes this + // armor, and the biggest number rides it so the whole haul fits inside. + if (pressed && self.carriedTreasureId) { + const fog = inHand(view, "mist-body"); + if (fog && !view.sustained.some((s) => s.cardId === "mist-body" && s.targetId === view.you)) { + const biggest = numbersInHand(view).at(-1); + return { + type: "cast", instanceId: fog.instanceId, + ...(biggest ? { numberInstanceIds: [biggest.instanceId] } : {}), + }; + } + } if (pressed && style !== "berserker") { // FEAR keeps every pursuer three spaces off. const scare = inHand(view, "fear"); @@ -1367,6 +1385,22 @@ export function automatonCommand( }; } } + // LOCK IN PLACE roots a delivery mid-stride: a carrier closing on + // their home loses a whole turn of legs and warp-steps. + { + const root = inHand(view, "lock-in-place"); + const runner = root ? visible.find((p) => + p.carriedTreasureId != null && + Math.abs(p.position.x - p.home.x) + Math.abs(p.position.y - p.home.y) <= 7) : undefined; + if (root && runner) { + const smallest = numbersInHand(view)[0]; + return { + type: "cast", instanceId: root.instanceId, + target: { kind: "player", playerId: runner.id }, + ...(smallest ? { numberInstanceIds: [smallest.instanceId] } : {}), + }; + } + } if (tier.buffs && !view.turn.attackUsed) { const smallest = numbersInHand(view)[0]; // ADRENALINE: when no single blow finishes the target but two would. @@ -1575,7 +1609,10 @@ export function automatonCommand( pathToward(view, self.position, enemyCells, { canUnlock }) ?? // No clean road to anything: grit the teeth and wade the hazards, // as any wizard would rather than stand in a pocket forever. - pathToward(view, self.position, enemyCells, { canUnlock, throughHazards: true }); + pathToward(view, self.position, enemyCells, { canUnlock, throughHazards: true }) ?? + // And only when even the hazard roads are severed does desperation + // dive through a thornbush jail. + pathToward(view, self.position, objectives, { canUnlock, throughHazards: true, throughJails: true }); // A shimmering illusion on the best crossing costs nothing to doubt: // roll the free test before spending any card on that wall. const doubted = bestWallCrossing(view, self, objectives, canUnlock); diff --git a/packages/engine/test/automaton.test.ts b/packages/engine/test/automaton.test.ts index b77bd96..3a1592c 100644 --- a/packages/engine/test/automaton.test.ts +++ b/packages/engine/test/automaton.test.ts @@ -979,3 +979,86 @@ describe("the denial planner offers only castable blocks", () => { } }); }); + +describe("game-six lessons from the table", () => { + function toBot(state: GameState): GameState { + 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("roots a carrier closing on home with LOCK IN PLACE", () => { + let { state } = createGame({ playerIds: ["foe", "bot"], seed: 42, sets: ["basic", "expansion1"] }); + state = toBot(state); + const bot = state.players.find((p) => p.id === "bot")!; + const foe = state.players.find((p) => p.id === "foe")!; + // The foe stands beside the bot, laden, a short walk from banking. + foe.position = { x: bot.position.x, y: bot.position.y }; + const gold = state.treasures.find((t) => t.owner === "bot" && t.position)!; + gold.position = null; + gold.carriedBy = "foe"; + foe.carriedTreasureId = gold.id; + foe.home = { ...bot.position }; + bot.hand = [{ cardId: "lock-in-place", instanceId: "LK" }]; + const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage"); + expect(cmd).toMatchObject({ type: "cast", instanceId: "LK", target: { kind: "player", playerId: "foe" } }); + expect(applyCommand(state, "bot", cmd!).ok).toBe(true); + }); + + it("a pressed carrier of any temperament turns to mist", () => { + let { state } = createGame({ playerIds: ["foe", "bot"], seed: 42, sets: ["basic", "expansion1"] }); + state = toBot(state); + const bot = state.players.find((p) => p.id === "bot")!; + const foe = state.players.find((p) => p.id === "foe")!; + // Bot hauls the foe's gold mid-journey with the foe breathing down its neck. + const gold = state.treasures.find((t) => t.owner === "foe" && t.position)!; + gold.position = null; + gold.carriedBy = "bot"; + bot.carriedTreasureId = gold.id; + bot.position = { ...foe.home }; + foe.position = { ...bot.position }; + bot.hand = [ + { cardId: "mist-body", instanceId: "MB" }, + { cardId: "number-3", instanceId: "N3" }, + { cardId: "number-5", instanceId: "N5" }, + ]; + const cmd = automatonCommand(viewFor(state, "bot"), "berserker", "archmage"); + expect(cmd).toMatchObject({ type: "cast", instanceId: "MB", numberInstanceIds: ["N5"] }); + const r = applyCommand(state, "bot", cmd!); + if (!r.ok) throw new Error(r.error); + expect(r.state.sustained.some((s) => s.cardId === "mist-body" && s.targetId === "bot")).toBe(true); + }); + + it("detours around a thornbush jail rather than diving in", () => { + let { state } = createGame({ playerIds: ["bot", "foe"], seed: 7, sets: ["basic", "expansion1"] }); + state = toBot(state); + const bot = state.players.find((p) => p.id === "bot")!; + bot.hand = []; + // Two exits from the bot's square: the short road east holds a + // thornbush, the long road stays clean. A jail is not a shortcut. + const sides = ["N", "S", "E", "W"] as const; + const open = sides.filter((s) => { + const n = { x: bot.position.x + (s === "E" ? 1 : s === "W" ? -1 : 0), + y: bot.position.y + (s === "S" ? 1 : s === "N" ? -1 : 0) }; + return state.board.cells[cellKey(n)] !== undefined; + }); + expect(open.length).toBeGreaterThanOrEqual(2); + const bushSide = open[0]!; + const freeSide = open[1]!; + for (const s2 of sides) { + state.edgeOverrides[edgeKey(bot.position, s2)] = + s2 === bushSide || s2 === freeSide ? "open" : "wall"; + } + const bushCell = { x: bot.position.x + (bushSide === "E" ? 1 : bushSide === "W" ? -1 : 0), + y: bot.position.y + (bushSide === "S" ? 1 : bushSide === "N" ? -1 : 0) }; + state.squareContents[cellKey(bushCell)] = { kind: "thornbush", damage: 0, createdBy: "foe" }; + const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage"); + // Whatever goal it picks, the first stride must not be into the bush. + expect(cmd).toMatchObject({ type: "move" }); + expect((cmd as { direction: string }).direction).not.toBe(bushSide); + expect(applyCommand(state, "bot", cmd!).ok).toBe(true); + }); +});