diff --git a/packages/engine/src/automaton.ts b/packages/engine/src/automaton.ts index 907c16a..681abf5 100644 --- a/packages/engine/src/automaton.ts +++ b/packages/engine/src/automaton.ts @@ -7,7 +7,7 @@ // BERSERKER for blood, the WORRIER for the shadows between the two. import { cardDef, type CardInstance } from "./cards"; -import { cellKey, edgeKey, neighbor, stepTarget, SIDES, type Cell, type Side } from "./board"; +import { cellKey, edgeKey, neighbor, opposite, stepTarget, SIDES, type Cell, type Side } from "./board"; import { sightedCellsFor, type GameView } from "./view"; import type { AmbushTrigger, Command, PlayerId } from "./game"; @@ -121,7 +121,9 @@ function discardValue(c: CardInstance): number { if (c.cardId === "speed" || c.cardId === "interrupt" || c.cardId === "opportunity-fire" || c.cardId === "ward" || c.cardId === "drop-object" || c.cardId === "gift-from-above" || c.cardId === "deja-vu" || c.cardId === "amplify" || c.cardId === "safe" || - c.cardId === "glue" || c.cardId === "destroy-wall") return 6; + c.cardId === "glue" || c.cardId === "destroy-wall" || + c.cardId === "pass-through-wall" || c.cardId === "create-wall" || + c.cardId === "fill-square-with-stone" || c.cardId === "thornbush") return 6; if (def.cardType === "number") return 4 + (def.value ?? 0); if (def.cardType === "attack") return 3; return 2; // situational neutrals go first @@ -150,8 +152,12 @@ function walkStep( return { to: n, viaDoor: !alreadyOpen }; } -/** Walking distance from any of `starts` to every reachable cell. */ -function distancesFrom(view: GameView, starts: Cell[], canUnlock: boolean): Map { +/** Walking distance from any of `starts` to every reachable cell. `avoid` + * lets a planner ask "and if this edge or square were blocked?". */ +function distancesFrom( + view: GameView, starts: Cell[], canUnlock: boolean, + avoid?: { edge?: string; cell?: string }, +): Map { const dist = new Map(); let frontier: Cell[] = []; for (const s of starts) { @@ -163,9 +169,11 @@ function distancesFrom(view: GameView, starts: Cell[], canUnlock: boolean): Map< const next: Cell[] = []; for (const c of frontier) { for (const dir of SIDES) { + if (avoid?.edge && edgeKey(c, dir) === avoid.edge) continue; const step = walkStep(view, c, dir, canUnlock); if (!step) continue; const k = cellKey(step.to); + if (avoid?.cell === k) continue; if (dist.has(k)) continue; if (view.squareContents[k]?.kind === "stone") continue; dist.set(k, depth); @@ -180,17 +188,21 @@ function distancesFrom(view: GameView, starts: Cell[], canUnlock: boolean): Map< return dist; } -/** - * The wall worth a DESTROY WALL on the march: the one whose removal most - * shortens the road to the objectives. The card wants line of sight to the - * wall, and its collapse deals 4 to anyone beside it — so the clockwork - * blasts from a distance, standing next to the wall only when no road - * exists at all and it can afford the bruise. - */ -function wallBlastTarget( - view: GameView, self: { position: Cell; life: number }, goals: Set, - canUnlock: boolean, normalDistance: number, -): { cell: Cell; side: Side } | null { +interface WallCrossing { + cell: Cell; + side: Side; + /** The side of the wall the clockwork approaches from... */ + near: Cell; + /** ...and the cell it would emerge into. */ + far: Cell; + /** Steps to the wall + through it + on to the nearest objective. */ + total: number; +} + +/** The one wall whose removal (or crossing) most shortens the road. */ +function bestWallCrossing( + view: GameView, self: { position: Cell }, goals: Set, canUnlock: boolean, +): WallCrossing | null { if (goals.size === 0) return null; const dHere = distancesFrom(view, [self.position], canUnlock); const goalCells = [...goals].map((k) => { @@ -198,7 +210,7 @@ function wallBlastTarget( return { x, y }; }); const dGoal = distancesFrom(view, goalCells, canUnlock); - let best: { cell: Cell; side: Side; total: number } | null = null; + let best: WallCrossing | null = null; for (const [key, state] of Object.entries(view.board.edges)) { if (state !== "wall") continue; const [kind, coords] = key.split(":") as [string, string]; @@ -212,9 +224,126 @@ function wallBlastTarget( const db = dGoal.get(cellKey(b)); if (da === undefined || db === undefined) continue; const total = da + 1 + db; - if (best === null || total < best.total) best = { cell, side, total }; + if (best === null || total < best.total) best = { cell, side, near: a, far: b, total }; } } + return best; +} + +/** + * Path denial: when an enemy's march threatens something dear — they carry + * one of the clockwork's treasures home, or close on its gold lying on the + * floor — find the blockade that lengthens their road the most. CREATE WALL + * seals a corridor line; FILL SQUARE WITH STONE bricks a square; THORNBUSH + * makes a square nobody walks through willingly. Only placements the engine + * would accept are offered (sighted, empty, off homes and warp tokens), and + * only when the detour costs the enemy 3+ extra steps. + */ +function pathDenial(view: GameView): Command | null { + const wall = inHand(view, "create-wall"); + const stone = inHand(view, "fill-square-with-stone"); + const bush = inHand(view, "thornbush"); + if (!wall && !stone && !bush) return null; + + // Threats, most urgent first: my treasure being carried to an enemy home, + // then an enemy within reach of my treasure on the floor. + const threats: { enemy: Cell; goal: Cell }[] = []; + for (const t of view.treasures) { + if (t.owner !== view.you || !t.carriedBy || t.carriedBy === view.you) continue; + const carrier = view.players.find((p) => p.id === t.carriedBy && p.alive); + if (carrier) threats.push({ enemy: carrier.position, goal: carrier.home }); + } + const floorGold = view.treasures.find((t) => t.owner === view.you && t.position && !t.carriedBy); + if (floorGold) { + for (const e of livingEnemies(view)) { + const d = Math.abs(e.position.x - floorGold.position!.x) + Math.abs(e.position.y - floorGold.position!.y); + if (d <= 6) threats.push({ enemy: e.position, goal: floorGold.position! }); + } + } + if (threats.length === 0) return null; + + const sighted = sightedCellsFor(view); + const cellCastable = (k: string): boolean => { + if (!sighted.has(k)) return false; + if (view.squareContents[k]) return false; + const [x, y] = k.split(",").map(Number) as [number, number]; + if (view.board.homes.some((h) => h.x === x && h.y === y)) return false; + if (view.players.some((p) => p.alive && cellKey(p.position) === k)) return false; + if (view.treasures.some((t) => t.position && cellKey(t.position) === k)) return false; + if ((view.groundObjects[k] ?? []).length > 0) return false; + if (view.dimWarps.some((w) => cellKey(w.a) === k || cellKey(w.b) === k)) return false; + return true; + }; + + for (const { enemy, goal } of threats) { + const dEnemy = distancesFrom(view, [enemy], true); + const oldDist = dEnemy.get(cellKey(goal)); + if (oldDist === undefined || oldDist === 0) continue; // already there, or already cut off + // Walk one shortest path back from the goal to the enemy. + const path: Cell[] = [goal]; + let cursor = goal; + for (let d = oldDist; d > 0; d--) { + let stepped = false; + for (const dir of SIDES) { + const s = walkStep(view, cursor, dir, true); + if (s && dEnemy.get(cellKey(s.to)) === d - 1) { + cursor = s.to; + path.push(cursor); + stepped = true; + break; + } + } + if (!stepped) break; + } + if (cellKey(path[path.length - 1]!) !== cellKey(enemy)) continue; + + let best: { cmd: Command; gain: number } | null = null; + const consider = (cmd: Command, avoid: { edge?: string; cell?: string }) => { + const after = distancesFrom(view, [enemy], true, avoid).get(cellKey(goal)) ?? Infinity; + const gain = after - oldDist; + if (gain >= 3 && (best === null || gain > best.gain)) best = { cmd, gain }; + }; + for (let i = 0; i + 1 < path.length; i++) { + // path runs goal -> enemy; block between consecutive squares. + const a = path[i + 1]!, b = path[i]!; + if (Math.abs(a.x - b.x) + Math.abs(a.y - b.y) !== 1) continue; // a warp hop, not a corridor + if (wall) { + const side: Side = b.x > a.x ? "E" : b.x < a.x ? "W" : b.y > a.y ? "S" : "N"; + const k = edgeKey(a, side); + if ((view.board.edges[k] ?? "open") === "open" && + (sighted.has(cellKey(a)) || sighted.has(cellKey(b)))) { + consider( + { type: "cast", instanceId: wall.instanceId, target: { kind: "edge", cell: a, side } }, + { edge: k }, + ); + } + } + const filler = stone ?? bush; + // Never brick the square the treasure needs to stay reachable on. + if (filler && cellKey(b) !== cellKey(goal) && cellCastable(cellKey(b))) { + consider( + { type: "cast", instanceId: filler.instanceId, target: { kind: "cell", cell: b } }, + { cell: cellKey(b) }, + ); + } + } + if (best !== null) return (best as { cmd: Command; gain: number }).cmd; + } + return null; +} + +/** + * The wall worth a DESTROY WALL on the march: the one whose removal most + * shortens the road to the objectives. The card wants line of sight to the + * wall, and its collapse deals 4 to anyone beside it — so the clockwork + * blasts from a distance, standing next to the wall only when no road + * exists at all and it can afford the bruise. + */ +function wallBlastTarget( + view: GameView, self: { position: Cell; life: number }, goals: Set, + canUnlock: boolean, normalDistance: number, +): { cell: Cell; side: Side } | null { + const best = bestWallCrossing(view, self, goals, canUnlock); if (!best) return null; const adjacent = cellKey(self.position) === cellKey(best.cell) || cellKey(self.position) === cellKey(neighbor(best.cell, best.side)); @@ -638,6 +767,11 @@ function selfCare(view: GameView, style: AutomatonStyle, tier: TierTraits): Comm }; } } + // Deny the road: wall, brick, or bush the path of whoever threatens the gold. + if (tier.guardGold) { + const denial = pathDenial(view); + if (denial) return denial; + } // Empty of violence: DEJA-VU pulls the best attack back from the pile. const dv = tier.dejaVu ? inHand(view, "deja-vu") : undefined; if (dv && !view.yourHand.some((c) => ATTACKS[c.cardId] != null)) { @@ -794,6 +928,28 @@ export function automatonCommand( return { type: "cast", instanceId: dw.instanceId, target: { kind: "edge", ...blast } }; } } + // A banked PASS THROUGH WALL crossing: walk to the best wall and step in. + if (self.passWallCharges > 0) { + const crossing = bestWallCrossing(view, self, objectives, canUnlock); + if (crossing && crossing.total < (path?.distance ?? Infinity)) { + if (cellKey(self.position) === cellKey(crossing.near)) { + const dir = cellKey(crossing.near) === cellKey(crossing.cell) + ? crossing.side : opposite(crossing.side); + return { type: "move", direction: dir }; + } + const approach = pathToward(view, self.position, new Set([cellKey(crossing.near)]), { canUnlock }); + if (approach) return { type: "move", direction: approach.dir }; + } + } + // Or bank one now, when a crossing beats the walk by enough to spend a card. + const ptw = inHand(view, "pass-through-wall"); + if (ptw && self.passWallCharges === 0 && !view.turn.actionsEnded) { + const crossing = bestWallCrossing(view, self, objectives, canUnlock); + const normal = path?.distance ?? Infinity; + if (crossing && (normal === Infinity || normal - crossing.total >= 4)) { + return { type: "cast", instanceId: ptw.instanceId }; + } + } if (path) { // A locked door on the very next step: use the key first. if (path.doorAhead) { diff --git a/packages/engine/src/view.ts b/packages/engine/src/view.ts index bd62ed0..2e7c0db 100644 --- a/packages/engine/src/view.ts +++ b/packages/engine/src/view.ts @@ -28,6 +28,8 @@ export interface PlayerPublicView { carriedTreasureId: string | null; lostTurns: number; extraTurns: number; + /** Banked PASS THROUGH WALL crossings — cast openly, so public knowledge. */ + passWallCharges: number; displayed: CardInstance[]; /** Which of the six physical wizard colors this player plays. */ colorIndex: number; @@ -118,6 +120,7 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView { carriedTreasureId: p.carriedTreasureId, lostTurns: p.lostTurns, extraTurns: p.extraTurns, + passWallCharges: p.passWallCharges, displayed: p.hand.filter((c) => p.displayed.includes(c.instanceId)), })), yourHand: you ? [...you.hand] : [], diff --git a/packages/engine/test/automaton.test.ts b/packages/engine/test/automaton.test.ts index 256f5ed..5ea326a 100644 --- a/packages/engine/test/automaton.test.ts +++ b/packages/engine/test/automaton.test.ts @@ -5,7 +5,7 @@ import { type GameState, type PlayerId, } from "../src/game"; -import { edgeKey } from "../src/board"; +import { cellKey, edgeKey } from "../src/board"; import { viewFor } from "../src/view"; import { automatonCommand, automatonFallback, type AutomatonStyle, type AutomatonTier } from "../src/automaton"; @@ -260,3 +260,73 @@ describe("the clockwork wields destroy wall", () => { } }); }); + +describe("the clockwork wields pass through wall", () => { + it("banks a crossing when bricked in, then steps through the wall", () => { + let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"], deckRev: 24 }); + while (actingSeat(state) !== "bot") { + 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")!; + bot.position = { x: 4, y: 4 }; + for (const side of ["N", "S", "E", "W"] as const) { + state.edgeOverrides[edgeKey(bot.position, side)] = "wall"; + } + state.players.find((p) => p.id === "other")!.position = { x: 0, y: 0 }; + bot.hand[0] = { instanceId: "pass-through-wall#T", cardId: "pass-through-wall" }; + const cast = automatonCommand(viewFor(state, "bot"), "hunter", "archmage"); + expect(cast).toMatchObject({ type: "cast", instanceId: "pass-through-wall#T" }); + let r = applyCommand(state, "bot", cast!); + if (!r.ok) throw new Error(r.error); + state = r.state; + expect(state.players.find((p) => p.id === "bot")!.passWallCharges).toBe(1); + // The charge is spent on a step through the bricks. + const step = automatonCommand(viewFor(state, "bot"), "hunter", "archmage"); + expect(step?.type).toBe("move"); + r = applyCommand(state, "bot", step!); + if (!r.ok) throw new Error(r.error); + const after = r.state.players.find((p) => p.id === "bot")!; + expect(cellKey(after.position)).not.toBe(cellKey({ x: 4, y: 4 })); + expect(after.passWallCharges).toBe(0); + }); +}); + +describe("the clockwork denies the road", () => { + it("walls a thief's corridor when its treasure is being carried home", () => { + let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"], deckRev: 24 }); + while (actingSeat(state) !== "bot") { + 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")!; + // The thief carries the bot's treasure down a one-lane tube to its home. + const t = state.treasures.find((t) => t.owner === "bot")!; + t.position = null; + t.carriedBy = "other"; + thief.carriedTreasureId = t.id; + thief.position = { x: 4, y: 2 }; + thief.home = { x: 4, y: 6 }; + for (let y = 2; y <= 6; y++) { + state.edgeOverrides[edgeKey({ x: 4, y }, "E")] = "wall"; + state.edgeOverrides[edgeKey({ x: 4, y }, "W")] = "wall"; + } + state.edgeOverrides[edgeKey({ x: 4, y: 6 }, "S")] = "wall"; + for (let y = 2; y <= 5; y++) { + state.edgeOverrides[edgeKey({ x: 4, y }, "S")] = "open"; + } + bot.position = { x: 4, y: 4 }; + bot.hand = [{ instanceId: "create-wall#T", cardId: "create-wall" }]; + const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage"); + expect(cmd).toMatchObject({ type: "cast", instanceId: "create-wall#T" }); + const r = applyCommand(state, "bot", cmd!); + if (!r.ok) throw new Error(r.error); + // The blockade must actually sever the thief's road home. + const target = (cmd as { target: { cell: { x: number; y: number }; side: string } }).target; + expect(["N", "S"]).toContain(target.side); + expect(target.cell.x).toBe(4); + }); +});