diff --git a/packages/engine/src/automaton.ts b/packages/engine/src/automaton.ts index ef21ba0..64fafcd 100644 --- a/packages/engine/src/automaton.ts +++ b/packages/engine/src/automaton.ts @@ -8,7 +8,7 @@ import { cardDef, type CardInstance } from "./cards"; import { cellKey, edgeKey, neighbor, opposite, stepTarget, SIDES, type Cell, type Side } from "./board"; -import { sightedCellsFor, type GameView } from "./view"; +import { bentSightFor, sightedCellsFor, type GameView } from "./view"; import type { AmbushTrigger, Command, PlayerId } from "./game"; export type AutomatonStyle = "hunter" | "berserker" | "worrier"; @@ -94,6 +94,22 @@ const STONES = new Set([ const SUMMONS = new Set(["troll", "skeleton", "wraith", "fire-imp", "shadow", "democratic-monster"]); const UNLOCKS = ["master-key", "pick-lock", "remove-lock"]; +/** Neutrals the brain has a play for — the shed and forced discards spare + * them. Everything here is reachable from some rung of the ladder. */ +const USEFUL_NEUTRALS = new Set([ + "speed", "interrupt", "opportunity-fire", "ward", "drop-object", + "gift-from-above", "deja-vu", "amplify", "safe", "glue", + // roadwork + "destroy-wall", "pass-through-wall", "dispel-creation", "stone-to-water", + "create-door", "dimensional-warp", + // path denial + "create-wall", "illusion-wall", "jam-lock", "boobytrap", + "fill-square-with-stone", "thornbush", "rosebush", "create-pit", + "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", +]); + function me(view: GameView) { return view.players.find((p) => p.id === view.you)!; } @@ -122,12 +138,7 @@ function discardValue(c: CardInstance, view: GameView, style?: AutomatonStyle): 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; - 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" || - c.cardId === "pass-through-wall" || c.cardId === "create-wall" || - c.cardId === "fill-square-with-stone" || c.cardId === "thornbush") return 6; + 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; if ((c.cardId === "mist-body" || c.cardId === "shrink") && style === "worrier") return 6; @@ -159,11 +170,12 @@ function walkStep( return { to: n, viaDoor: !alreadyOpen }; } -/** Walking distance from any of `starts` to every reachable cell. `avoid` - * lets a planner ask "and if this edge or square were blocked?". */ +/** Walking distance from any of `starts` to every reachable cell. `mod` + * lets a planner ask "and if this edge or square were blocked?" (avoid...) + * or "and if this wall or filled square were gone?" (open...). */ function distancesFrom( view: GameView, starts: Cell[], canUnlock: boolean, - avoid?: { edge?: string; cell?: string }, + mod?: { avoidEdge?: string; avoidCell?: string; openEdge?: string; openCell?: string }, ): Map { const dist = new Map(); let frontier: Cell[] = []; @@ -176,17 +188,22 @@ function distancesFrom( 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 (mod?.avoidEdge && edgeKey(c, dir) === mod.avoidEdge) continue; + let step = walkStep(view, c, dir, canUnlock); + if (!step && mod?.openEdge === edgeKey(c, dir)) { + const n = neighbor(c, dir); + if (view.board.cells[cellKey(n)]) step = { to: n, viaDoor: false }; + } if (!step) continue; const k = cellKey(step.to); - if (avoid?.cell === k) continue; + if (mod?.avoidCell === k) continue; if (dist.has(k)) continue; - if (view.squareContents[k]?.kind === "stone") continue; + const opened = mod?.openCell === k; + if (view.squareContents[k]?.kind === "stone" && !opened) continue; dist.set(k, depth); const hazard = view.squareContents[k]?.kind; - if (hazard === "pit" || hazard === "ooze" || hazard === "thornbush" || - hazard === "rosebush" || hazard === "slime") continue; + if (!opened && (hazard === "pit" || hazard === "ooze" || hazard === "thornbush" || + hazard === "rosebush" || hazard === "slime")) continue; next.push(step.to); } } @@ -246,11 +263,18 @@ function bestWallCrossing( * would accept are offered (sighted, empty, off homes and warp tokens), and * only when the detour costs the enemy 3+ extra steps. */ +/** Square-filling nuisances for path denial, best blocker first. */ +const DENIAL_FILLERS = [ + "fill-square-with-stone", "thornbush", "rosebush", "create-pit", + "fill-square-with-slime", "killer-ooze", "handful-of-tacks", "dust-cloud", +]; + 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; + const wall = inHand(view, "create-wall") ?? inHand(view, "illusion-wall"); + const jam = inHand(view, "jam-lock"); + const filler = DENIAL_FILLERS.map((id) => inHand(view, id)).find(Boolean); + const trap = inHand(view, "boobytrap"); + if (!wall && !jam && !filler && !trap) 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. @@ -305,8 +329,8 @@ function pathDenial(view: GameView): Command | null { 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 consider = (cmd: Command, mod: { avoidEdge?: string; avoidCell?: string }) => { + const after = distancesFrom(view, [enemy], true, mod).get(cellKey(goal)) ?? Infinity; const gain = after - oldDist; if (gain >= 3 && (best === null || gain > best.gain)) best = { cmd, gain }; }; @@ -314,31 +338,182 @@ function pathDenial(view: GameView): Command | null { // 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 side: Side = b.x > a.x ? "E" : b.x < a.x ? "W" : b.y > a.y ? "S" : "N"; + const k = edgeKey(a, side); + const edgeSighted = sighted.has(cellKey(a)) || sighted.has(cellKey(b)); + if (wall && (view.board.edges[k] ?? "open") === "open" && edgeSighted) { + consider( + { type: "cast", instanceId: wall.instanceId, target: { kind: "edge", cell: a, side } }, + { avoidEdge: k }, + ); + } + // A door on their road jams shut for good — cheaper than a wall. + if (jam && (view.board.edges[k] ?? "open") === "door" && edgeSighted && + view.doorStates[k] !== "jammed" && view.doorStates[k] !== "removed") { + consider( + { type: "cast", instanceId: jam.instanceId, target: { kind: "edge", cell: a, side } }, + { avoidEdge: 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) }, + { avoidCell: cellKey(b) }, ); } } if (best !== null) return (best as { cmd: Command; gain: number }).cmd; + + // Nothing reroutes them? A BOOBYTRAP mid-path punishes the march instead: + // the real token on their road, three bluffs beside it. + if (trap && path.length >= 3 && view.boobytraps.every((b) => b.casterId !== view.you)) { + const mid = path[Math.floor(path.length / 2)]!; + const spots: Cell[] = [mid]; + for (const dir of SIDES) { + if (spots.length === 4) break; + const n = neighbor(mid, dir); + const nk = cellKey(n); + if (view.board.cells[nk] && view.squareContents[nk]?.kind !== "stone" && + !spots.some((s) => cellKey(s) === nk)) { + spots.push(n); + } + } + if (spots.length === 4) { + return { type: "cast", instanceId: trap.instanceId, params: { cells: spots } }; + } + } } return null; } +/** + * Beyond DESTROY WALL, the hand holds other roadwork: + * - DISPEL CREATION un-creates a conjured wall or a filled square. + * - STONE TO WATER melts any wall or stone block — cast from outside the + * wave, which would otherwise wash the caster backward. + * - CREATE DOOR plus a lock-opener turns a wall into a doorway. + * - DIMENSIONAL WARP folds a long march into a single step. + * Costs one card (two for the door), so only a real shortcut pays. + */ +function roadworkPlan( + view: GameView, self: { position: Cell }, goals: Set, + canUnlock: boolean, normalDist: 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; + + const sighted = sightedCellsFor(view); + const dHere = distancesFrom(view, [self.position], canUnlock); + const goalCells = [...goals].map((k) => { + const [x, y] = k.split(",").map(Number) as [number, number]; + return { x, y }; + }); + const dGoal = distancesFrom(view, goalCells, canUnlock); + let best: { cmd: Command; total: number } | null = null; + const offer = (cmd: Command, total: number, minGain: number) => { + const gain = normalDist - total; + if (gain >= minGain && (best === null || total < best.total)) best = { cmd, total }; + }; + + // Wall edges: dispellable if conjured; meltable by STONE TO WATER always. + const created = new Set(view.createdEdges); + for (const [key, state] of Object.entries(view.board.edges)) { + if (state !== "wall" && state !== "firewall") continue; + const [kind, coords] = key.split(":") as [string, string]; + const [x, y] = coords.split(",").map(Number) as [number, number]; + const cell = { x, y }; + const side: Side = kind === "V" ? "E" : "S"; + const beyond = neighbor(cell, side); + if (!view.board.cells[cellKey(beyond)]) continue; + if (!sighted.has(cellKey(cell)) && !sighted.has(cellKey(beyond))) continue; + let total = 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 (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 (s2w && state === "wall") { + // The collapsing wave covers two cells each side of the wall — melt + // it only from outside its reach. + const away = opposite(side); + const wet = [cell, neighbor(cell, away), beyond, neighbor(beyond, side)]; + if (!wet.some((w) => cellKey(w) === cellKey(self.position))) { + offer({ type: "cast", instanceId: s2w.instanceId, target }, total, 4); + } + } + if (cdoor && state === "wall") { + // Two cards (door + key), so the shortcut must earn more. + offer({ type: "cast", instanceId: cdoor.instanceId, target }, total, 6); + } + } + + // Filled squares: melting or dispelling one joins its open neighbors. + for (const [k, content] of Object.entries(view.squareContents)) { + if (!sighted.has(k)) continue; + const [x, y] = k.split(",").map(Number) as [number, number]; + const c = { x, y }; + const openNeighbors = SIDES + .map((dir) => ({ dir, n: neighbor(c, dir) })) + .filter(({ dir, n }) => + view.board.cells[cellKey(n)] && + (view.board.edges[edgeKey(c, dir)] ?? "open") === "open"); + let total = Infinity; + for (const { n: n1 } of openNeighbors) { + for (const { n: n2 } of openNeighbors) { + if (cellKey(n1) === cellKey(n2)) continue; + const da = dHere.get(cellKey(n1)); + const db = dGoal.get(cellKey(n2)); + if (da !== undefined && db !== undefined) total = Math.min(total, da + 2 + db); + } + } + if (total === Infinity) continue; + const target = { kind: "cell" as const, cell: c }; + if (dispel) offer({ type: "cast", instanceId: dispel.instanceId, target }, total, 4); + if (s2w && content.kind === "stone") { + // The melted block bursts a range-4 wave down all four corridors. + const inWave = (self.position.x === x || self.position.y === y) && + Math.abs(self.position.x - x) + Math.abs(self.position.y - y) <= 4; + if (!inWave) offer({ type: "cast", instanceId: s2w.instanceId, target }, total, 4); + } + } + + if (best === null && dwarp) { + // A long march folds through a warp pair: one token underfoot, one by + // the goal. Tokens shun home bases and solid stone. + const legal = (c: Cell) => + view.board.cells[cellKey(c)] !== undefined && + !view.board.homes.some((h) => h.x === c.x && h.y === c.y) && + view.squareContents[cellKey(c)]?.kind !== "stone" && + !view.dimWarps.some((w) => cellKey(w.a) === cellKey(c) || cellKey(w.b) === cellKey(c)); + if (legal(self.position)) { + let far: { cell: Cell; d: number } | null = null; + for (const [k, d] of dGoal) { + if (d > 2) continue; + const [x, y] = k.split(",").map(Number) as [number, number]; + if (!legal({ x, y })) continue; + if (far === null || d < far.d) far = { cell: { x, y }, d }; + } + if (far && normalDist - (1 + far.d) >= 6) { + return { + type: "cast", instanceId: dwarp.instanceId, + params: { cell: self.position }, target: { kind: "cell", cell: far.cell }, + }; + } + } + } + return best !== null ? (best as { cmd: Command; total: number }).cmd : 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 @@ -756,6 +931,43 @@ function selfCare(view: GameView, style: AutomatonStyle, tier: TierTraits): Comm } } } + // Breathing room for anyone pressed: the worrier by temperament, any + // wizard hauling gold, anyone bleeding out. + const pressed = enemyNear && + (style === "worrier" || self.carriedTreasureId != null || self.life <= 5); + if (pressed && style !== "berserker") { + // FEAR keeps every pursuer three spaces off. + const scare = inHand(view, "fear"); + if (scare && !view.sustained.some((s) => s.cardId === "fear" && s.targetId === view.you)) { + return { + type: "cast", instanceId: scare.instanceId, + ...(mid ? { numberInstanceIds: [mid.instanceId] } : {}), + }; + } + // UGLY shoves everyone in sight away — best when they are on top of you. + const hideous = inHand(view, "ugly"); + if (hideous) { + const sighted = sightedCellsFor(view); + const crowded = livingEnemies(view).some((p) => + sighted.has(cellKey(p.position)) && + Math.abs(p.position.x - self.position.x) + Math.abs(p.position.y - self.position.y) <= 2); + if (crowded) return { type: "cast", instanceId: hideous.instanceId }; + } + // BUDDY pacifies the nearest hound: they cannot strike first. + const pact = inHand(view, "buddy"); + if (pact) { + const sighted = sightedCellsFor(view); + const hound = livingEnemies(view) + .filter((p) => sighted.has(cellKey(p.position)) && + !view.sustained.some((s) => s.cardId === "buddy" && s.casterId === view.you && s.targetId === p.id)) + .sort((a, b) => + (Math.abs(a.position.x - self.position.x) + Math.abs(a.position.y - self.position.y)) - + (Math.abs(b.position.x - self.position.x) + Math.abs(b.position.y - self.position.y)))[0]; + if (hound) { + return { type: "cast", instanceId: pact.instanceId, target: { kind: "player", playerId: hound.id } }; + } + } + } // Guard the gold on the floor: a SAFE locks it, GLUE sticks it down. const myFloorTreasure = tier.guardGold ? view.treasures.find((t) => t.owner === view.you && t.position && !t.carriedBy) @@ -900,6 +1112,19 @@ export function automatonCommand( return { type: "punch", targetId: target.id }; } } + // AROUND THE CORNER: an enemy tucked one bend out of sight is not safe. + if (visible.length === 0 && tier.buffs) { + const corner = inHand(view, "around-the-corner"); + if (corner) { + for (const p of livingEnemies(view)) { + if (!bentSightFor(view, self.position, p.position)) continue; + const spell = bestAttack(view, p.id, tier); + if (spell && spell.type === "cast") { + return { ...spell, aroundCornerInstanceId: corner.instanceId }; + } + } + } + } // No shot at a wizard: raise a creature to do the walking. const summon = view.yourHand.find((c) => SUMMONS.has(c.cardId)); if (summon && livingEnemies(view).length > 0) { @@ -957,6 +1182,22 @@ export function automatonCommand( return { type: "cast", instanceId: ptw.instanceId }; } } + // The rest of the toolbox: dispel, melt, door-and-key, warp tokens. + if (!view.turn.actionsEnded) { + const work = roadworkPlan(view, self, objectives, canUnlock, path?.distance ?? Infinity); + if (work) return work; + } + // Standing on a warp token whose far side is closer to the goal: step in. + { + const here = cellKey(self.position); + const pair = view.dimWarps.find((w) => cellKey(w.a) === here || cellKey(w.b) === here); + if (pair) { + const dest = cellKey(pair.a) === here ? pair.b : pair.a; + const dFar = distancesFrom(view, [dest], canUnlock); + const farBest = Math.min(...[...objectives].map((g) => dFar.get(g) ?? Infinity)); + if (farBest + 1 < (path?.distance ?? Infinity)) return { type: "warpStep" }; + } + } if (path) { // A locked door on the very next step: use the key first. if (path.doorAhead) { @@ -971,18 +1212,18 @@ export function automatonCommand( } } else { const movesLeft = view.turn.movementAllowance - view.turn.movementUsed; + const numbers = numbersInHand(view); + // 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); + }); + const spendable = wantsNumber ? numbers.slice(0, -1) : numbers; if (!view.turn.numberPlayedForMovement && path.distance > movesLeft) { - const numbers = numbersInHand(view); - // 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); - }); - const spendable = wantsNumber ? numbers.slice(0, -1) : numbers; const helper = spendable.find( (n) => movesLeft + (cardDef(n.cardId).value ?? 0) >= path.distance, ) ?? (path.distance > movesLeft + 2 ? spendable[spendable.length - 1] : undefined); @@ -990,6 +1231,43 @@ export function automatonCommand( return { type: "playNumberForMovement", instanceId: helper.instanceId }; } } + // ADD joins a second number when the first still leaves the goal shy. + if (view.turn.numberPlayedForMovement && !view.turn.movementAddUsed && + path.distance > movesLeft && spendable.length > 0) { + const add = inHand(view, "add"); + if (add) { + const second = spendable.find( + (n) => movesLeft + (cardDef(n.cardId).value ?? 0) >= path.distance, + ) ?? (path.distance > movesLeft + 2 ? spendable[spendable.length - 1] : undefined); + if (second) { + return { + type: "playNumberForMovement", + instanceId: second.instanceId, addInstanceId: add.instanceId, + }; + } + } + } + // MAD DASH doubles what the legs already have — for the sprint to + // gold that would otherwise take another turn. Not while carrying. + if (path.distance > movesLeft && path.distance >= movesLeft + 3 && + path.distance <= movesLeft * 2 && !self.carriedTreasureId && + !view.turn.actionsEnded) { + const dash = inHand(view, "mad-dash"); + if (dash) return { type: "cast", instanceId: dash.instanceId }; + } + // POWER RUN buys the last spaces of a winning delivery with blood. + if (path.distance > movesLeft && self.carriedTreasureId && !view.turn.actionsEnded) { + const carried = view.treasures.find((t) => t.id === self.carriedTreasureId); + const stolenAtHome = view.treasures.filter( + (t) => t.owner !== you && t.position && cellKey(t.position) === cellKey(self.home), + ).length; + const points = path.distance - movesLeft; + if (carried && carried.owner !== you && stolenAtHome >= 1 && + points <= 4 && self.life - points >= 5) { + const run = inHand(view, "power-run"); + if (run) return { type: "cast", instanceId: run.instanceId, params: { points } }; + } + } return { type: "move", direction: path.dir }; } } diff --git a/packages/engine/src/view.ts b/packages/engine/src/view.ts index 2e7c0db..02d63d0 100644 --- a/packages/engine/src/view.ts +++ b/packages/engine/src/view.ts @@ -66,6 +66,8 @@ export interface GameView { openDoorEdges: string[]; /** Door edges held open by a standing wizard. */ heldDoorEdges: string[]; + /** Edges conjured into being (walls, doors, firewalls) - dispellable. */ + createdEdges: string[]; /** Illusion edges YOU know are fake (creator or saw through); others see walls. */ knownIllusionEdges: string[]; creatures: CreatureState[]; @@ -143,6 +145,7 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView { wallDamage: { ...state.wallDamage }, openDoorEdges: [...state.openDoorEdges], heldDoorEdges: state.heldDoors.map((h) => h.key), + createdEdges: Object.keys(state.createdEdges), knownIllusionEdges, creatures: state.creatures.map((c) => ({ ...c, scorchedThisTurn: [...c.scorchedThisTurn] })), wandCharges: { ...state.wandCharges }, @@ -215,6 +218,25 @@ export function traceSightFor(view: GameView, from: Cell, to: Cell): SightTrace return traceSight(board, from, to, blockers); } +/** + * AROUND THE CORNER's bent sight, from this viewer's knowledge: the caster + * sees a middle square which sees the target — mirroring the engine's + * bentLos so a client can predict whether the modifier will land. + */ +export function bentSightFor(view: GameView, from: Cell, to: Cell): boolean { + const { board, blockers } = sightBasis(view); + if (sightBetween(board, from, to, blockers)) return true; + for (const key of Object.keys(board.cells)) { + if (blockers[key]) continue; + const [mx, my] = key.split(",").map(Number) as [number, number]; + const mid = { x: mx, y: my }; + if (sightBetween(board, from, mid, blockers) && sightBetween(board, mid, to, blockers)) { + return true; + } + } + return false; +} + /** * The sight line behind the attack currently on the stack — the board's * answer to "how can he even see me?". Null when nothing should draw: diff --git a/packages/engine/test/automaton.test.ts b/packages/engine/test/automaton.test.ts index 5ea326a..c4b4599 100644 --- a/packages/engine/test/automaton.test.ts +++ b/packages/engine/test/automaton.test.ts @@ -181,7 +181,7 @@ describe("a clogged hand gets shed, not hoarded", () => { const bot = state.players.find((p) => p.id === "bot")!; // Seven situational neutrals the brain has no play for: a dead hand. bot.hand = Array.from({ length: 7 }, (_, i) => ( - { instanceId: `illusion-wall#${i}`, cardId: "illusion-wall" } + { instanceId: `rotate-sector#${i}`, cardId: "rotate-sector" } )); // Walk the bot's turn until it wants to end: it must shed before drawing. for (let guard = 0; guard < 30; guard++) { @@ -244,9 +244,9 @@ describe("the clockwork wields destroy wall", () => { const bot = state.players.find((p) => p.id === "bot")!; bot.hand = [ { instanceId: "destroy-wall#T", cardId: "destroy-wall" }, - { instanceId: "buddy#T", cardId: "buddy" }, - { instanceId: "ugly#T", cardId: "ugly" }, - { instanceId: "fear#T", cardId: "fear" }, + { instanceId: "trader#T", cardId: "trader" }, + { instanceId: "strength#T", cardId: "strength" }, + { instanceId: "adrenaline#T", cardId: "adrenaline" }, { instanceId: "full-shield#T", cardId: "full-shield" }, { instanceId: "fireball#T", cardId: "fireball" }, { instanceId: "number-3#T", cardId: "number-3" }, @@ -330,3 +330,47 @@ describe("the clockwork denies the road", () => { expect(target.cell.x).toBe(4); }); }); + +describe("the widened spellbook", () => { + it("dispels a conjured wall standing between it and the only road", () => { + let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"], 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) { + const k = edgeKey(bot.position, side); + state.edgeOverrides[k] = "wall"; + state.createdEdges[k] = true; + } + state.players.find((p) => p.id === "other")!.position = { x: 0, y: 0 }; + bot.hand = [{ instanceId: "dispel-creation#T", cardId: "dispel-creation" }]; + const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage"); + expect(cmd).toMatchObject({ type: "cast", instanceId: "dispel-creation#T" }); + const r = applyCommand(state, "bot", cmd!); + if (!r.ok) throw new Error(r.error); + }); + + it("offers a buddy pact to the hound at its heels", () => { + let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"], 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 hound = state.players.find((p) => p.id === "other")!; + hound.position = { ...bot.position }; + bot.hand = [{ instanceId: "buddy#T", cardId: "buddy" }]; + const cmd = automatonCommand(viewFor(state, "bot"), "worrier", "archmage"); + expect(cmd).toEqual({ + type: "cast", instanceId: "buddy#T", + target: { kind: "player", playerId: "other" }, + }); + const r = applyCommand(state, "bot", cmd!); + if (!r.ok) throw new Error(r.error); + }); +});