From 39ddbb452d22a19df3c1bb032ea1070742d67a62 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Fri, 21 Aug 2026 17:53:26 -0400 Subject: [PATCH] The clockwork wades, shelters, and honors pacts it can actually break MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Game 6XDY's prisoners expose a nest of turn-burners. The pathfinder treated pits, ooze, bushes, and slime as walls in transit, so bots stood "trapped" in pockets any human would wade out of: the march now falls back to a teeth-gritted hazard route when no clean road exists. Bots no longer aim attacks into (or out of) a thornbush's shelter the engine refuses; WIZARDBLADE learns it is a blade (same square only); a cursed IDIOT skips every rung the curse would refuse instead of burning turns on them; BUDDY's orientation is righted — whoever pacted YOU is untouchable, your own pacts are merely precious — and a thief standing underfoot no longer becomes a null destination that freezes two laden wizards on the same square forever. Co-Authored-By: Claude Fable 5 --- packages/engine/src/automaton.ts | 455 +++++++++++++------------ packages/engine/test/automaton.test.ts | 75 ++++ 2 files changed, 318 insertions(+), 212 deletions(-) diff --git a/packages/engine/src/automaton.ts b/packages/engine/src/automaton.ts index e9315b5..d6271ea 100644 --- a/packages/engine/src/automaton.ts +++ b/packages/engine/src/automaton.ts @@ -49,7 +49,7 @@ const ATTACKS: Record, - opts: { avoidNearEnemies?: boolean; canUnlock?: boolean } = {}, + opts: { avoidNearEnemies?: boolean; canUnlock?: boolean; throughHazards?: boolean } = {}, ): PathResult | null { if (goals.size === 0 || goals.has(cellKey(from))) return null; const enemyCells = livingEnemies(view).map((p) => p.position); @@ -593,9 +593,11 @@ function pathToward( seen.add(k); 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. const hazard = view.squareContents[k]?.kind; - if (hazard === "pit" || hazard === "ooze" || hazard === "thornbush" || - hazard === "rosebush" || hazard === "slime") continue; + if (!opts.throughHazards && + (hazard === "pit" || hazard === "ooze" || hazard === "thornbush" || + hazard === "rosebush" || hazard === "slime")) continue; if (opts.avoidNearEnemies && nearEnemy(to)) continue; next.push(to); } @@ -635,6 +637,14 @@ function treasureGoals(view: GameView): Set { return goals; } +/** Bushes shelter both ways and mist can neither strike nor be struck — + * the engine refuses such attacks, and a refused choice burns the turn. */ +function bushOrMist(view: GameView, id: PlayerId): boolean { + const p = view.players.find((q) => q.id === id)!; + return view.squareContents[cellKey(p.position)]?.kind === "thornbush" || + view.sustained.some((s) => s.cardId === "mist-body" && s.targetId === id); +} + /** The wizard making off with MY gold, if any. */ function thiefOfMine(view: GameView) { const carriers = new Set( @@ -1109,226 +1119,239 @@ export function automatonCommand( if (view.turn.actionsEnded) return endTurnDrawing(view, tier, style); - // Deliver or grab treasure underfoot. - if (self.carriedTreasureId && here === cellKey(self.home)) return { type: "dropTreasure" }; - if (!self.carriedTreasureId) { - const prize = view.treasures.find( - (t) => t.position && !t.carriedBy && t.owner !== you && cellKey(t.position) === here && - here !== cellKey(self.home), - ); - if (prize) return { type: "pickUpTreasure" }; - } - - // Wounded clockwork teleports clear of visible hunters. - if (self.life <= 5) { - const sighted = sightedCellsFor(view); - const hunted = livingEnemies(view).some((p) => sighted.has(cellKey(p.position))); - const tp = inHand(view, "teleport"); - if (hunted && tp) { - const out = escapeCell(view, self.position); - if (out) return { type: "cast", instanceId: tp.instanceId, target: { kind: "cell", cell: out } }; - } - } - - 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; - const enemyHere = livingEnemies(view).find((p) => cellKey(p.position) === cellKey(c.position)); - if (enemyHere && !c.attackUsed && - (c.kind === "troll" || c.kind === "skeleton" || c.kind === "shadow")) { - return { type: "creatureAttack", creatureId: c.id, targetId: enemyHere.id }; - } - if (c.movementUsed < c.movesPerTurn) { - const enemyCells = new Set(livingEnemies(view).map((p) => cellKey(p.position))); - const hunt = pathToward(view, c.position, enemyCells, {}); - if (hunt && !hunt.doorAhead) return { type: "moveCreature", creatureId: c.id, direction: hunt.dir }; - } - } - + // IDIOT bars everything but counteractions, the march's aids, and the + // jailbreak drop below: any other choice burns the turn on a refusal. + const cursedIdiot = view.sustained.some((e) => e.cardId === "idiot" && e.targetId === view.you); const thief = thiefOfMine(view); + if (!cursedIdiot) { - // One attack per turn: the thief of my gold dies first, then the weakest. - // 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); - // A wizard under the clockwork's own BUDDY pact is off the target list: - // attacking them would tear up the pact it just paid a card for. - const pacted = (id: PlayerId) => - view.sustained.some((s) => s.cardId === "buddy" && s.casterId === you && s.targetId === id); - const visible = livingEnemies(view) - .filter((p) => sighted.has(cellKey(p.position)) && !pacted(p.id)); - if (visible.length > 0) { - 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) { - return { - type: "cast", instanceId: dob.instanceId, - target: { kind: "player", playerId: thief.id }, params: { cardId: "treasure" }, - }; - } - } - // 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.cmd; - const misery = tier.afflictions ? bestAffliction(view, target.id, thief?.id === target.id) : null; - if (misery) return misery; - // STRENGTH in force and the treasure in reach: wrest it from their - // arms — worth the attack even for the timid. - if (cellKey(target.position) === here && target.carriedTreasureId && - view.sustained.some((e) => e.cardId === "strength" && e.targetId === view.you)) { - return { type: "tearTreasure", targetId: target.id }; - } - if (cellKey(target.position) === here && style !== "worrier") { - return { type: "punch", targetId: target.id }; - } + // Deliver or grab treasure underfoot. + if (self.carriedTreasureId && here === cellKey(self.home)) return { type: "dropTreasure" }; + if (!self.carriedTreasureId) { + const prize = view.treasures.find( + (t) => t.position && !t.carriedBy && t.owner !== you && cellKey(t.position) === here && + here !== cellKey(self.home), + ); + if (prize) return { type: "pickUpTreasure" }; } - // Only pacted wizards in sight? A pact is torn up for one thing: a kill. - const pactedVisible = livingEnemies(view) - .filter((p) => sighted.has(cellKey(p.position)) && pacted(p.id)) - .sort((a, b) => a.life - b.life); - for (const p of pactedVisible) { - const spell = bestAttack(view, p.id, tier); - if (spell && spell.damage >= p.life) return spell.cmd; - } - // 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 (pacted(p.id)) continue; - if (!bentSightFor(view, self.position, p.position)) continue; - const spell = bestAttack(view, p.id, tier); - if (spell && spell.cmd.type === "cast") { - return { ...spell.cmd, 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) { - const near = style === "worrier" ? self.position : livingEnemies(view)[0]!.position; - const spot = summonSpot(view, near); - if (spot) { - return { type: "cast", instanceId: summon.instanceId, target: { kind: "cell", cell: spot } }; - } - } - } - // BUDDY after the blow: the wounded cannot come looking for payback — - // the pact holds unless the clockwork strikes them again. Signed only - // when the clockwork actually wants OUT of the fight (hauling gold, - // bleeding, or with other enemies left to hunt); a healthy duelist keeps - // its options, and the berserker would only break its own pact tomorrow. - if (tier.buffs && view.turn.attackUsed && style !== "berserker" && - (self.carriedTreasureId != null || self.life <= 8 || livingEnemies(view).length > 1)) { - const pact = inHand(view, "buddy"); - if (pact) { + // Wounded clockwork teleports clear of visible hunters. + if (self.life <= 5) { const sighted = sightedCellsFor(view); - const mark = livingEnemies(view) - .filter((p) => sighted.has(cellKey(p.position)) && - !view.sustained.some((s) => s.cardId === "buddy" && s.casterId === you && s.targetId === p.id)) - .sort((a, b) => a.life - b.life)[0]; - if (mark) { - return { type: "cast", instanceId: pact.instanceId, target: { kind: "player", playerId: mark.id } }; + const hunted = livingEnemies(view).some((p) => sighted.has(cellKey(p.position))); + const tp = inHand(view, "teleport"); + if (hunted && tp) { + const out = escapeCell(view, self.position); + if (out) return { type: "cast", instanceId: tp.instanceId, target: { kind: "cell", cell: out } }; } } - } - // FEAR's compulsion: "must move away from you on their turn if within - // range, if they can." Caught in a dread bubble, the clockwork spends - // its legs fleeing before any other marching. - if (view.turn.movementUsed < view.turn.movementAllowance) { - const dreadful = livingEnemies(view).filter((p) => - view.sustained.some((e) => e.cardId === "fear" && e.targetId === p.id) && - Math.abs(p.position.x - self.position.x) + Math.abs(p.position.y - self.position.y) <= 3); - for (const source of dreadful) { - const distNow = Math.abs(source.position.x - self.position.x) + Math.abs(source.position.y - self.position.y); - let best: { dir: Side; d: number } | null = null; - for (const dir of SIDES) { - const t = stepTarget(view.board, self.position, dir); - if (t.kind === "blocked") continue; - if (view.squareContents[cellKey(t.to)]?.kind === "stone") continue; - const d = Math.abs(source.position.x - t.to.x) + Math.abs(source.position.y - t.to.y); - if (d > distNow && (best === null || d > best.d)) best = { dir, d }; + 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" }, + }; + } } - if (best) return { type: "move", direction: best.dir }; - // Dead end: the card excuses what cannot be done; march on normally. } + + // Command the menagerie: creatures march and maul before the wizard moves. + for (const c of view.creatures) { + if (c.controllerId !== you || c.justCreated) continue; + const enemyHere = livingEnemies(view).find((p) => cellKey(p.position) === cellKey(c.position)); + if (enemyHere && !c.attackUsed && + (c.kind === "troll" || c.kind === "skeleton" || c.kind === "shadow")) { + return { type: "creatureAttack", creatureId: c.id, targetId: enemyHere.id }; + } + if (c.movementUsed < c.movesPerTurn) { + const enemyCells = new Set(livingEnemies(view).map((p) => cellKey(p.position))); + const hunt = pathToward(view, c.position, enemyCells, {}); + if (hunt && !hunt.doorAhead) return { type: "moveCreature", creatureId: c.id, direction: hunt.dir }; + } + } + + // One attack per turn: the thief of my gold dies first, then the weakest. + // 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); + // A wizard under the clockwork's own BUDDY pact is off the target list: + // attacking them would tear up the pact it just paid a card for. + // Whoever cast BUDDY on you is safe from your first strike — the + // engine refuses the attack outright. Your OWN pacts are merely + // precious: striking someone you pacted tears up your protection. + const shieldedFromMe = (id: PlayerId) => + view.sustained.some((s) => s.cardId === "buddy" && s.casterId === id && s.targetId === you); + const myPact = (id: PlayerId) => + view.sustained.some((s) => s.cardId === "buddy" && s.casterId === you && s.targetId === id); + const visible = bushOrMist(view, you) ? [] : livingEnemies(view) + .filter((p) => sighted.has(cellKey(p.position)) && + !shieldedFromMe(p.id) && !myPact(p.id) && !bushOrMist(view, p.id)); + if (visible.length > 0) { + 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) { + return { + type: "cast", instanceId: dob.instanceId, + target: { kind: "player", playerId: thief.id }, params: { cardId: "treasure" }, + }; + } + } + // 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.cmd; + const misery = tier.afflictions ? bestAffliction(view, target.id, thief?.id === target.id) : null; + if (misery) return misery; + // STRENGTH in force and the treasure in reach: wrest it from their + // arms — worth the attack even for the timid. + if (cellKey(target.position) === here && target.carriedTreasureId && + view.sustained.some((e) => e.cardId === "strength" && e.targetId === view.you)) { + return { type: "tearTreasure", targetId: target.id }; + } + if (cellKey(target.position) === here && style !== "worrier") { + return { type: "punch", targetId: target.id }; + } + } + // Only your own pacted wizards in sight? A pact you signed is torn + // up for one thing: a kill. (Pacts signed ON you cannot be broken.) + const pactedVisible = livingEnemies(view) + .filter((p) => sighted.has(cellKey(p.position)) && myPact(p.id) && + !shieldedFromMe(p.id) && !bushOrMist(view, p.id)) + .sort((a, b) => a.life - b.life); + for (const p of pactedVisible) { + const spell = bestAttack(view, p.id, tier); + if (spell && spell.damage >= p.life) return spell.cmd; + } + // 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 (shieldedFromMe(p.id) || myPact(p.id) || bushOrMist(view, p.id)) continue; + if (!bentSightFor(view, self.position, p.position)) continue; + const spell = bestAttack(view, p.id, tier); + if (spell && spell.cmd.type === "cast") { + return { ...spell.cmd, 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) { + const near = style === "worrier" ? self.position : livingEnemies(view)[0]!.position; + const spot = summonSpot(view, near); + if (spot) { + return { type: "cast", instanceId: summon.instanceId, target: { kind: "cell", cell: spot } }; + } + } + } + + // BUDDY after the blow: the wounded cannot come looking for payback — + // the pact holds unless the clockwork strikes them again. Signed only + // when the clockwork actually wants OUT of the fight (hauling gold, + // bleeding, or with other enemies left to hunt); a healthy duelist keeps + // its options, and the berserker would only break its own pact tomorrow. + if (tier.buffs && view.turn.attackUsed && style !== "berserker" && + (self.carriedTreasureId != null || self.life <= 8 || livingEnemies(view).length > 1)) { + const pact = inHand(view, "buddy"); + if (pact) { + const sighted = sightedCellsFor(view); + const mark = livingEnemies(view) + .filter((p) => sighted.has(cellKey(p.position)) && + !view.sustained.some((s) => s.cardId === "buddy" && s.casterId === you && s.targetId === p.id)) + .sort((a, b) => a.life - b.life)[0]; + if (mark) { + return { type: "cast", instanceId: pact.instanceId, target: { kind: "player", playerId: mark.id } }; + } + } + } + + // FEAR's compulsion: "must move away from you on their turn if within + // range, if they can." Caught in a dread bubble, the clockwork spends + // its legs fleeing before any other marching. + if (view.turn.movementUsed < view.turn.movementAllowance) { + const dreadful = livingEnemies(view).filter((p) => + view.sustained.some((e) => e.cardId === "fear" && e.targetId === p.id) && + Math.abs(p.position.x - self.position.x) + Math.abs(p.position.y - self.position.y) <= 3); + for (const source of dreadful) { + const distNow = Math.abs(source.position.x - self.position.x) + Math.abs(source.position.y - self.position.y); + let best: { dir: Side; d: number } | null = null; + for (const dir of SIDES) { + const t = stepTarget(view.board, self.position, dir); + if (t.kind === "blocked") continue; + if (view.squareContents[cellKey(t.to)]?.kind === "stone") continue; + const d = Math.abs(source.position.x - t.to.x) + Math.abs(source.position.y - t.to.y); + if (d > distNow && (best === null || d > best.d)) best = { dir, d }; + } + if (best) return { type: "move", direction: best.dir }; + // Dead end: the card excuses what cannot be done; march on normally. + } + } + } // IDIOT's curse: the engine never forces the feet, so the clockwork // honors the march to its own gold itself — and when a thief carries // that gold, DROP OBJECT (the one attack the curse permits) shakes it // onto the floor where it can finally be stood upon. - const cursedIdiot = view.sustained.some((e) => e.cardId === "idiot" && e.targetId === view.you); if (cursedIdiot && view.turn.round > 1 && !view.turn.attackUsed) { const dropper = inHand(view, "drop-object"); if (dropper) { @@ -1354,17 +1377,25 @@ export function automatonCommand( const ownGoldCells = new Set(view.treasures .filter((t) => t.owner === view.you && t.position && !t.carriedBy) .map((t) => cellKey(t.position!))); + // A thief already underfoot is not a destination — with the chase + // moot, march on the real goals (deliver, grab) instead of standing + // on them forever. + const thiefAfar = thief && cellKey(thief.position) !== here ? thief : null; const objectives = cursedIdiot && ownGoldCells.size > 0 ? ownGoldCells - : thief - ? new Set([cellKey(thief.position)]) + : thiefAfar + ? new Set([cellKey(thiefAfar.position)]) : style === "berserker" && !self.carriedTreasureId ? (enemyCells.size > 0 ? enemyCells : gold) : gold; const path = - pathToward(view, self.position, objectives, { avoidNearEnemies: style === "worrier" && !thief, canUnlock }) ?? + pathToward(view, self.position, objectives, { avoidNearEnemies: style === "worrier" && !thiefAfar, canUnlock }) ?? pathToward(view, self.position, objectives, { canUnlock }) ?? - pathToward(view, self.position, enemyCells, { canUnlock }); + 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, objectives, { canUnlock, throughHazards: true }) ?? + pathToward(view, self.position, enemyCells, { canUnlock, throughHazards: 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 7ad5fcd..1debece 100644 --- a/packages/engine/test/automaton.test.ts +++ b/packages/engine/test/automaton.test.ts @@ -283,6 +283,81 @@ describe("the clockwork honors IDIOT's march", () => { }); }); +describe("the clockwork wades hazards rather than surrender", () => { + it("takes the slime road when no clean path to anything exists", () => { + let { state } = createGame({ playerIds: ["bot", "foe"], seed: 7, sets: ["basic", "expansion1"] }); + 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.hand = []; + // Wall the bot into its square except one side; slime that one exit. + const sides = ["N", "S", "E", "W"] as const; + const board = state.board; + 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 board.cells[cellKey(n)] !== undefined; + }); + const exit = open[0]!; + for (const s2 of sides) { + if (s2 !== exit) state.edgeOverrides[edgeKey(bot.position, s2)] = "wall"; + else state.edgeOverrides[edgeKey(bot.position, s2)] = "open"; + } + const beyond = { x: bot.position.x + (exit === "E" ? 1 : exit === "W" ? -1 : 0), + y: bot.position.y + (exit === "S" ? 1 : exit === "N" ? -1 : 0) }; + state.squareContents[cellKey(beyond)] = { kind: "slime", damage: 0, createdBy: "foe" }; + const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage"); + expect(cmd).toEqual({ type: "move", direction: exit }); + expect(applyCommand(state, "bot", cmd!).ok).toBe(true); + }); +}); + +describe("the clockwork respects the bush's shelter", () => { + function faceOffWithFireball() { + let { state } = createGame({ playerIds: ["bot", "foe"], seed: 7, sets: ["basic", "expansion1"] }); + for (let i = 0; i < 2; i++) { + const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 }); + if (!r.ok) throw new Error(r.error); + state = r.state; + } + 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 foe = state.players.find((p) => p.id === "foe")!; + foe.position = { ...bot.position }; + bot.hand = [{ instanceId: "fireball#T", cardId: "fireball" }]; + return { state, bot, foe }; + } + + it("never aims at a wizard sheltered in a thornbush", () => { + const { state, foe } = faceOffWithFireball(); + state.squareContents[cellKey(foe.position)] = { kind: "thornbush", damage: 0, createdBy: "foe" }; + const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage") + ?? automatonFallback(viewFor(state, "bot"), "archmage"); + // Whatever it picks, the engine must accept it — and it must not be + // the refused fireball that would burn the whole turn. + expect(cmd).not.toMatchObject({ type: "cast", instanceId: "fireball#T" }); + expect(applyCommand(state, "bot", cmd).ok).toBe(true); + }); + + it("never attacks out of its own bush", () => { + const { state, bot, foe } = faceOffWithFireball(); + foe.position = { x: bot.position.x, y: bot.position.y }; + state.squareContents[cellKey(bot.position)] = { kind: "thornbush", damage: 0, createdBy: "foe" }; + foe.position = { ...bot.position }; + const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage") + ?? automatonFallback(viewFor(state, "bot"), "archmage"); + expect(cmd).not.toMatchObject({ type: "cast", instanceId: "fireball#T" }); + expect(applyCommand(state, "bot", cmd).ok).toBe(true); + }); +}); + describe("a clogged hand gets shed, not hoarded", () => { it("the bot discards dead weight so the end-of-turn draw has room", () => { let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"] });