The clockwork wades, shelters, and honors pacts it can actually break
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
76207c6264
commit
39ddbb452d
+243
-212
@@ -49,7 +49,7 @@ const ATTACKS: Record<string, { base: number; perNumber: boolean; needsNumber?:
|
|||||||
powerthrust: { base: 2, perNumber: true },
|
powerthrust: { base: 2, perNumber: true },
|
||||||
waterbolt: { base: 0, perNumber: true, needsNumber: true },
|
waterbolt: { base: 0, perNumber: true, needsNumber: true },
|
||||||
"lightning-blast": { base: 0, perNumber: true, needsNumber: true },
|
"lightning-blast": { base: 0, perNumber: true, needsNumber: true },
|
||||||
wizardblade: { base: 0, perNumber: true, needsNumber: true },
|
wizardblade: { base: 0, perNumber: true, needsNumber: true, sameSquare: true },
|
||||||
"power-drain": { base: 0, perNumber: true, needsNumber: true },
|
"power-drain": { base: 0, perNumber: true, needsNumber: true },
|
||||||
dagger: { base: 3, perNumber: false },
|
dagger: { base: 3, perNumber: false },
|
||||||
"large-rock": { base: 2, perNumber: false },
|
"large-rock": { base: 2, perNumber: false },
|
||||||
@@ -569,7 +569,7 @@ function pathToward(
|
|||||||
view: GameView,
|
view: GameView,
|
||||||
from: Cell,
|
from: Cell,
|
||||||
goals: Set<string>,
|
goals: Set<string>,
|
||||||
opts: { avoidNearEnemies?: boolean; canUnlock?: boolean } = {},
|
opts: { avoidNearEnemies?: boolean; canUnlock?: boolean; throughHazards?: boolean } = {},
|
||||||
): PathResult | null {
|
): PathResult | null {
|
||||||
if (goals.size === 0 || goals.has(cellKey(from))) return null;
|
if (goals.size === 0 || goals.has(cellKey(from))) return null;
|
||||||
const enemyCells = livingEnemies(view).map((p) => p.position);
|
const enemyCells = livingEnemies(view).map((p) => p.position);
|
||||||
@@ -593,9 +593,11 @@ function pathToward(
|
|||||||
seen.add(k);
|
seen.add(k);
|
||||||
cameBy.set(k, { prev: cellKey(c), dir, viaDoor });
|
cameBy.set(k, { prev: cellKey(c), dir, viaDoor });
|
||||||
if (goals.has(k)) { found = k; break; }
|
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;
|
const hazard = view.squareContents[k]?.kind;
|
||||||
if (hazard === "pit" || hazard === "ooze" || hazard === "thornbush" ||
|
if (!opts.throughHazards &&
|
||||||
hazard === "rosebush" || hazard === "slime") continue;
|
(hazard === "pit" || hazard === "ooze" || hazard === "thornbush" ||
|
||||||
|
hazard === "rosebush" || hazard === "slime")) continue;
|
||||||
if (opts.avoidNearEnemies && nearEnemy(to)) continue;
|
if (opts.avoidNearEnemies && nearEnemy(to)) continue;
|
||||||
next.push(to);
|
next.push(to);
|
||||||
}
|
}
|
||||||
@@ -635,6 +637,14 @@ function treasureGoals(view: GameView): Set<string> {
|
|||||||
return goals;
|
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. */
|
/** The wizard making off with MY gold, if any. */
|
||||||
function thiefOfMine(view: GameView) {
|
function thiefOfMine(view: GameView) {
|
||||||
const carriers = new Set(
|
const carriers = new Set(
|
||||||
@@ -1109,226 +1119,239 @@ export function automatonCommand(
|
|||||||
|
|
||||||
if (view.turn.actionsEnded) return endTurnDrawing(view, tier, style);
|
if (view.turn.actionsEnded) return endTurnDrawing(view, tier, style);
|
||||||
|
|
||||||
// Deliver or grab treasure underfoot.
|
// IDIOT bars everything but counteractions, the march's aids, and the
|
||||||
if (self.carriedTreasureId && here === cellKey(self.home)) return { type: "dropTreasure" };
|
// jailbreak drop below: any other choice burns the turn on a refusal.
|
||||||
if (!self.carriedTreasureId) {
|
const cursedIdiot = view.sustained.some((e) => e.cardId === "idiot" && e.targetId === view.you);
|
||||||
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 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const thief = thiefOfMine(view);
|
const thief = thiefOfMine(view);
|
||||||
|
if (!cursedIdiot) {
|
||||||
|
|
||||||
// One attack per turn: the thief of my gold dies first, then the weakest.
|
// Deliver or grab treasure underfoot.
|
||||||
// ADRENALINE stretches that to two while it lasts.
|
if (self.carriedTreasureId && here === cellKey(self.home)) return { type: "dropTreasure" };
|
||||||
const adrenalized = view.sustained.some((s) => s.cardId === "adrenaline" && s.targetId === you);
|
if (!self.carriedTreasureId) {
|
||||||
if ((!view.turn.attackUsed || (adrenalized && !view.turn.secondAttackUsed)) &&
|
const prize = view.treasures.find(
|
||||||
view.turn.round > 1) {
|
(t) => t.position && !t.carriedBy && t.owner !== you && cellKey(t.position) === here &&
|
||||||
const sighted = sightedCellsFor(view);
|
here !== cellKey(self.home),
|
||||||
// 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.
|
if (prize) return { type: "pickUpTreasure" };
|
||||||
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 };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// 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 —
|
// Wounded clockwork teleports clear of visible hunters.
|
||||||
// the pact holds unless the clockwork strikes them again. Signed only
|
if (self.life <= 5) {
|
||||||
// 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 sighted = sightedCellsFor(view);
|
||||||
const mark = livingEnemies(view)
|
const hunted = livingEnemies(view).some((p) => sighted.has(cellKey(p.position)));
|
||||||
.filter((p) => sighted.has(cellKey(p.position)) &&
|
const tp = inHand(view, "teleport");
|
||||||
!view.sustained.some((s) => s.cardId === "buddy" && s.casterId === you && s.targetId === p.id))
|
if (hunted && tp) {
|
||||||
.sort((a, b) => a.life - b.life)[0];
|
const out = escapeCell(view, self.position);
|
||||||
if (mark) {
|
if (out) return { type: "cast", instanceId: tp.instanceId, target: { kind: "cell", cell: out } };
|
||||||
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
|
const care = selfCare(view, style, tier);
|
||||||
// range, if they can." Caught in a dread bubble, the clockwork spends
|
if (care) return care;
|
||||||
// its legs fleeing before any other marching.
|
|
||||||
if (view.turn.movementUsed < view.turn.movementAllowance) {
|
// MEGA-MONSTER: a doubled stride turns a pet into a bloodhound.
|
||||||
const dreadful = livingEnemies(view).filter((p) =>
|
if (tier.buffs && livingEnemies(view).length > 0) {
|
||||||
view.sustained.some((e) => e.cardId === "fear" && e.targetId === p.id) &&
|
const mm = inHand(view, "mega-monster");
|
||||||
Math.abs(p.position.x - self.position.x) + Math.abs(p.position.y - self.position.y) <= 3);
|
if (mm) {
|
||||||
for (const source of dreadful) {
|
const sighted = sightedCellsFor(view);
|
||||||
const distNow = Math.abs(source.position.x - self.position.x) + Math.abs(source.position.y - self.position.y);
|
const pet = view.creatures.find((c) =>
|
||||||
let best: { dir: Side; d: number } | null = null;
|
c.controllerId === you && c.kind !== "shadow" && c.kind !== "alter-ego" &&
|
||||||
for (const dir of SIDES) {
|
c.movesPerTurn <= 3 && sighted.has(cellKey(c.position)));
|
||||||
const t = stepTarget(view.board, self.position, dir);
|
if (pet) {
|
||||||
if (t.kind === "blocked") continue;
|
return {
|
||||||
if (view.squareContents[cellKey(t.to)]?.kind === "stone") continue;
|
type: "cast", instanceId: mm.instanceId,
|
||||||
const d = Math.abs(source.position.x - t.to.x) + Math.abs(source.position.y - t.to.y);
|
target: { kind: "creature", creatureId: pet.id }, params: { boost: "movement" },
|
||||||
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.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// 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
|
// 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
|
// that gold, DROP OBJECT (the one attack the curse permits) shakes it
|
||||||
// onto the floor where it can finally be stood upon.
|
// 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) {
|
if (cursedIdiot && view.turn.round > 1 && !view.turn.attackUsed) {
|
||||||
const dropper = inHand(view, "drop-object");
|
const dropper = inHand(view, "drop-object");
|
||||||
if (dropper) {
|
if (dropper) {
|
||||||
@@ -1354,17 +1377,25 @@ export function automatonCommand(
|
|||||||
const ownGoldCells = new Set(view.treasures
|
const ownGoldCells = new Set(view.treasures
|
||||||
.filter((t) => t.owner === view.you && t.position && !t.carriedBy)
|
.filter((t) => t.owner === view.you && t.position && !t.carriedBy)
|
||||||
.map((t) => cellKey(t.position!)));
|
.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
|
const objectives = cursedIdiot && ownGoldCells.size > 0
|
||||||
? ownGoldCells
|
? ownGoldCells
|
||||||
: thief
|
: thiefAfar
|
||||||
? new Set([cellKey(thief.position)])
|
? new Set([cellKey(thiefAfar.position)])
|
||||||
: style === "berserker" && !self.carriedTreasureId
|
: style === "berserker" && !self.carriedTreasureId
|
||||||
? (enemyCells.size > 0 ? enemyCells : gold)
|
? (enemyCells.size > 0 ? enemyCells : gold)
|
||||||
: gold;
|
: gold;
|
||||||
const path =
|
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, 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:
|
// A shimmering illusion on the best crossing costs nothing to doubt:
|
||||||
// roll the free test before spending any card on that wall.
|
// roll the free test before spending any card on that wall.
|
||||||
const doubted = bestWallCrossing(view, self, objectives, canUnlock);
|
const doubted = bestWallCrossing(view, self, objectives, canUnlock);
|
||||||
|
|||||||
@@ -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", () => {
|
describe("a clogged hand gets shed, not hoarded", () => {
|
||||||
it("the bot discards dead weight so the end-of-turn draw has room", () => {
|
it("the bot discards dead weight so the end-of-turn draw has room", () => {
|
||||||
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"] });
|
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"] });
|
||||||
|
|||||||
Reference in New Issue
Block a user