The clockwork minds the pit, the curse, and the shadow (GDCN)

Kestrel's archmage berserker dug a pit at the far mouth of a warp,
then tried to walk through that mouth seven times: each stride landed
on its own pit with a thornbush beyond, the maze bounced it back and
charged the stride, and on the seventh it rolled the 1 and fell in.
Cursed with SLOW DEATH at seven life, it kept drawing two cards a
turn at a point apiece, raised a SHADOW that drinks a point a turn,
and bled to death in three turns.

The path search now refuses a pit it cannot leap beyond — the same
test the maze applies — whether the pit is a waypoint or the goal.
Under SLOW DEATH the end-of-turn draw keeps four life in hand and
draws nothing when it cannot; while bleeding, or at six life or less,
no SHADOW is raised. Three tests pin them.

The chronicle tells the bounce as what it was: "leaps the pit —
nothing to land on beyond it — and teeters back", not a blind blunder
into a wall; and the shadow's last drink no longer trails a zero-damage
line. Brain and chronicle changes only, ungated; 83 ledgers replay.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
Eric Wagoner
2026-09-04 16:11:42 -04:00
co-authored by Claude Fable 5.1
parent b92016db51
commit 0750fe3f21
4 changed files with 125 additions and 10 deletions
+81 -1
View File
@@ -8,7 +8,7 @@ import {
} from "../src/game";
import { cellKey, edgeKey } from "../src/board";
import { sightedCellsFor, viewFor } from "../src/view";
import { automatonCommand, automatonFallback, type AutomatonStyle, type AutomatonTier } from "../src/automaton";
import { automatonCommand, automatonFallback, drawUnderCurses, pathToward, type AutomatonStyle, type AutomatonTier } from "../src/automaton";
import { pushSustained } from "./helpers";
/** Whose input does the maze want right now? */
@@ -1110,3 +1110,83 @@ describe("interception, escape, and hazard sense", () => {
expect(applyCommand(state, "bot", cmd!).ok).toBe(true);
});
});
describe("the clockwork under a curse, and before a pit", () => {
/** A two-seat game advanced to the bot's own turn in round 2. */
function botsTurn() {
let { state } = createGame({ playerIds: ["human", "bot"], seed: 7, sets: ["basic", "expansion1"] });
for (let guard = 0; guard < 8; guard++) {
if (state.turn.round >= 2 && actingSeat(state) === "bot") break;
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(`setup: ${r.error}`);
state = r.state;
}
return state;
}
it("draws only what SLOW DEATH lets it afford", () => {
const state = botsTurn();
const bot = state.players.find((p) => p.id === "bot")!;
const plain = viewFor(state, "bot");
expect(drawUnderCurses(plain, 2)).toBe(2);
pushSustained(state, { id: "sd", cardId: "slow-death", casterId: "human", targetId: "bot", remainingTurns: 1e9 });
bot.life = 7;
expect(drawUnderCurses(viewFor(state, "bot"), 2)).toBe(2);
bot.life = 5;
expect(drawUnderCurses(viewFor(state, "bot"), 2)).toBe(1);
bot.life = 4;
expect(drawUnderCurses(viewFor(state, "bot"), 2)).toBe(0);
});
it("raises no SHADOW while bleeding, nor with little blood to spare", () => {
for (const rig of [
(s: GameState) => pushSustained(s, { id: "sd", cardId: "slow-death", casterId: "human", targetId: "bot", remainingTurns: 1e9 }),
(s: GameState) => { s.players.find((p) => p.id === "bot")!.life = 5; },
]) {
let state = botsTurn();
rig(state);
const bot = state.players.find((p) => p.id === "bot")!;
bot.hand = [{ instanceId: "shadow#T", cardId: "shadow" }];
// Play the bot's whole turn: nothing it does may be the shadow.
for (let guard = 0; guard < 30 && actingSeat(state) === "bot"; guard++) {
const view = viewFor(state, "bot");
const cmd = automatonCommand(view, "berserker", "archmage") ?? automatonFallback(view, "archmage");
expect(JSON.stringify(cmd)).not.toContain("shadow#T");
const r = applyCommand(state, "bot", cmd);
if (!r.ok) break;
state = r.state;
}
}
});
it("never plans a stride into a pit it cannot leap beyond", () => {
const state = botsTurn();
const bot = state.players.find((p) => p.id === "bot")!;
const view0 = viewFor(state, "bot");
// Stand the bot at the head of a straight three-square run, dig a pit
// in the middle square, and wall the far one with stone: the route in
// from here is no route. With the stone gone, the leap is a road again.
const delta = { N: { x: 0, y: -1 }, E: { x: 1, y: 0 }, S: { x: 0, y: 1 }, W: { x: -1, y: 0 } } as const;
for (const k of Object.keys(view0.board.cells)) {
const [x, y] = k.split(",").map(Number) as [number, number];
for (const dir of ["N", "E", "S", "W"] as const) {
const pit = { x: x + delta[dir].x, y: y + delta[dir].y };
const beyond = { x: pit.x + delta[dir].x, y: pit.y + delta[dir].y };
if (!view0.board.cells[cellKey(pit)] || !view0.board.cells[cellKey(beyond)]) continue;
if ((view0.board.edges[edgeKey({ x, y }, dir)] ?? "open") !== "open") continue;
if ((view0.board.edges[edgeKey(pit, dir)] ?? "open") !== "open") continue;
if (state.squareContents[k] || state.squareContents[cellKey(pit)] || state.squareContents[cellKey(beyond)]) continue;
bot.position = { x, y };
state.squareContents[cellKey(pit)] = { kind: "pit", damage: 0, createdBy: "human" };
state.squareContents[cellKey(beyond)] = { kind: "stone", damage: 0, createdBy: "human" };
const blocked = pathToward(viewFor(state, "bot"), bot.position, new Set([cellKey(pit)]), { throughHazards: true });
expect(blocked === null || blocked.dir !== dir).toBe(true);
delete state.squareContents[cellKey(beyond)];
const open = pathToward(viewFor(state, "bot"), bot.position, new Set([cellKey(pit)]), { throughHazards: true });
expect(open?.dir).toBe(dir);
return;
}
}
throw new Error("no straight three-square run on this board");
});
});