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:
Eric Wagoner
2026-08-21 17:53:26 -04:00
co-authored by Claude Fable 5
parent 76207c6264
commit 39ddbb452d
2 changed files with 318 additions and 212 deletions
+48 -17
View File
@@ -49,7 +49,7 @@ const ATTACKS: Record<string, { base: number; perNumber: boolean; needsNumber?:
powerthrust: { base: 2, perNumber: true },
waterbolt: { 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 },
dagger: { base: 3, perNumber: false },
"large-rock": { base: 2, perNumber: false },
@@ -569,7 +569,7 @@ function pathToward(
view: GameView,
from: Cell,
goals: Set<string>,
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<string> {
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,6 +1119,12 @@ export function automatonCommand(
if (view.turn.actionsEnded) return endTurnDrawing(view, tier, style);
// 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) {
// Deliver or grab treasure underfoot.
if (self.carriedTreasureId && here === cellKey(self.home)) return { type: "dropTreasure" };
if (!self.carriedTreasureId) {
@@ -1165,8 +1181,6 @@ export function automatonCommand(
}
}
const thief = thiefOfMine(view);
// 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);
@@ -1175,10 +1189,16 @@ export function automatonCommand(
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) =>
// 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 = livingEnemies(view)
.filter((p) => sighted.has(cellKey(p.position)) && !pacted(p.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
@@ -1249,9 +1269,11 @@ export function automatonCommand(
return { type: "punch", targetId: target.id };
}
}
// Only pacted wizards in sight? A pact is torn up for one thing: a kill.
// 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)) && pacted(p.id))
.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);
@@ -1262,7 +1284,7 @@ export function automatonCommand(
const corner = inHand(view, "around-the-corner");
if (corner) {
for (const p of livingEnemies(view)) {
if (pacted(p.id)) continue;
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") {
@@ -1324,11 +1346,12 @@ export function automatonCommand(
}
}
}
// 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);
+75
View File
@@ -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"] });