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
+34 -6
View File
@@ -582,7 +582,16 @@ function wallBlastTarget(
* when the clockwork can unlock them; the first such door is reported so the * when the clockwork can unlock them; the first such door is reported so the
* key gets used before the boot. * key gets used before the boot.
*/ */
function pathToward( /** Can a wizard entering the pit at `pit` heading `dir` land beyond it —
* the same test the maze applies: a cell there, no wall between, no stone. */
function pitLandable(view: GameView, pit: Cell, dir: Side): boolean {
const beyond = neighbor(pit, dir);
return !!view.board.cells[cellKey(beyond)] &&
(view.board.edges[edgeKey(pit, dir)] ?? "open") === "open" &&
view.squareContents[cellKey(beyond)]?.kind !== "stone";
}
export function pathToward(
view: GameView, view: GameView,
from: Cell, from: Cell,
goals: Set<string>, goals: Set<string>,
@@ -607,6 +616,11 @@ function pathToward(
const k = cellKey(to); const k = cellKey(to);
if (seen.has(k)) continue; if (seen.has(k)) continue;
if (view.squareContents[k]?.kind === "stone") continue; if (view.squareContents[k]?.kind === "stone") continue;
const hazard = view.squareContents[k]?.kind;
// A pit is entered by leaping to the square beyond it in the same
// direction; with nothing to land on, the maze bounces the leaper
// back and charges the stride. Goal or waypoint, that is no road.
if (hazard === "pit" && !pitLandable(view, to, dir)) continue;
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; }
@@ -615,7 +629,6 @@ function pathToward(
// turn, and stings — a jail with foliage. Only a wizard with no // turn, and stings — a jail with foliage. Only a wizard with no
// road at all (throughJails) dives in. "Few will dive into one // road at all (throughJails) dives in. "Few will dive into one
// unless they are desperate." // unless they are desperate."
const hazard = view.squareContents[k]?.kind;
if (hazard === "thornbush" && !opts.throughJails) continue; if (hazard === "thornbush" && !opts.throughJails) continue;
if (!opts.throughHazards && if (!opts.throughHazards &&
(hazard === "pit" || hazard === "ooze" || (hazard === "pit" || hazard === "ooze" ||
@@ -725,7 +738,8 @@ function overLimit(view: GameView): number {
* clogged hand never refreshes; only cards the brain has no play for are * clogged hand never refreshes; only cards the brain has no play for are
* shed (good counters and attacks are hoarded, as a human would). */ * shed (good counters and attacks are hoarded, as a human would). */
function endTurnDrawing(view: GameView, tier: TierTraits, style?: AutomatonStyle): Command { function endTurnDrawing(view: GameView, tier: TierTraits, style?: AutomatonStyle): Command {
const deficit = tier.draw - (handLimitOf(view) - view.yourHand.length); const draw = drawUnderCurses(view, tier.draw);
const deficit = draw - (handLimitOf(view) - view.yourHand.length);
if (tier.sheds && deficit > 0) { if (tier.sheds && deficit > 0) {
const shed = [...view.yourHand] const shed = [...view.yourHand]
.filter((c) => discardValue(c, view, style) <= 3) .filter((c) => discardValue(c, view, style) <= 3)
@@ -734,7 +748,16 @@ function endTurnDrawing(view: GameView, tier: TierTraits, style?: AutomatonStyle
.map((c) => c.instanceId); .map((c) => c.instanceId);
if (shed.length > 0) return { type: "discard", instanceIds: shed }; if (shed.length > 0) return { type: "discard", instanceIds: shed };
} }
return { type: "endTurn", draw: tier.draw }; return { type: "endTurn", draw };
}
/** SLOW DEATH bleeds a point per card drawn: the clockwork draws only what
* it can afford, keeping four life in hand, and nothing at all when it
* cannot — a bare hand beats a bare grave. */
export function drawUnderCurses(view: GameView, draw: number): number {
const slow = view.sustained.some((e) => e.cardId === "slow-death" && e.targetId === view.you && !e.data?.reversed);
if (!slow) return draw;
return Math.max(0, Math.min(draw, me(view).life - 4));
} }
function worstCards(view: GameView, n: number, style?: AutomatonStyle): string[] { function worstCards(view: GameView, n: number, style?: AutomatonStyle): string[] {
@@ -1522,8 +1545,13 @@ export function automatonCommand(
} }
} }
} }
// No shot at a wizard: raise a creature to do the walking. // No shot at a wizard: raise a creature to do the walking. A SHADOW
const summon = view.yourHand.find((c) => SUMMONS.has(c.cardId)); // drinks a life a turn from its master — no pet for a wizard already
// bleeding, or with little blood to spare.
const bleeding = view.sustained.some((e) =>
(e.cardId === "slow-death" || e.cardId === "walking-dead") && e.targetId === you && !e.data?.reversed);
const summon = view.yourHand.find((c) => SUMMONS.has(c.cardId) &&
!(c.cardId === "shadow" && (bleeding || self.life <= 6)));
if (summon && livingEnemies(view).length > 0) { if (summon && livingEnemies(view).length > 0) {
const near = style === "worrier" ? self.position : livingEnemies(view)[0]!.position; const near = style === "worrier" ? self.position : livingEnemies(view)[0]!.position;
const spot = summonSpot(view, near); const spot = summonSpot(view, near);
+4 -2
View File
@@ -648,7 +648,9 @@ export type GameEvent =
| { type: "objectDragged"; caster: PlayerId; what: string; from: Cell; to: Cell } | { type: "objectDragged"; caster: PlayerId; what: string; from: Cell; to: Cell }
| { type: "spellReused"; player: PlayerId; card: CardInstance } | { type: "spellReused"; player: PlayerId; card: CardInstance }
| { type: "castAroundCorner"; caster: PlayerId } | { type: "castAroundCorner"; caster: PlayerId }
| { type: "moveBumped"; player: PlayerId; direction: Side } /** A stride that went nowhere: into a wall while blind, or into a pit
* with nothing to land on beyond it. */
| { type: "moveBumped"; player: PlayerId; direction: Side; why?: "pit" }
| { type: "attackMisdirected"; attacker: PlayerId; intended: PlayerId; rolledDirection: Side; newTarget: PlayerId | null } | { type: "attackMisdirected"; attacker: PlayerId; intended: PlayerId; rolledDirection: Side; newTarget: PlayerId | null }
| { type: "dieRolled"; player: PlayerId | null; roll: number; purpose: string } | { type: "dieRolled"; player: PlayerId | null; roll: number; purpose: string }
| { type: "tableTalk"; player: PlayerId; text: string } | { type: "tableTalk"; player: PlayerId; text: string }
@@ -4352,7 +4354,7 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult {
// Nowhere to land: teeter back where you started. // Nowhere to land: teeter back where you started.
p.position = from; p.position = from;
state.turn.movementUsed++; state.turn.movementUsed++;
events.push({ type: "moveBumped", player: p.id, direction }); events.push({ type: "moveBumped", player: p.id, direction, why: "pit" });
return { ok: true, state, events }; return { ok: true, state, events };
} }
} }
+81 -1
View File
@@ -8,7 +8,7 @@ import {
} from "../src/game"; } from "../src/game";
import { cellKey, edgeKey } from "../src/board"; import { cellKey, edgeKey } from "../src/board";
import { sightedCellsFor, viewFor } from "../src/view"; 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"; import { pushSustained } from "./helpers";
/** Whose input does the maze want right now? */ /** 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); 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");
});
});
+6 -1
View File
@@ -51,6 +51,9 @@ export function humanize(e: GameEvent): string | null {
if (e.fullyStopped) return `The attack is completely stopped.`; if (e.fullyStopped) return `The attack is completely stopped.`;
return null; // the damaged event tells the story return null; // the damaged event tells the story
case "damaged": { case "damaged": {
// The shadow's last drink is told by its own line; the bookkeeping
// blow that follows it has nothing to add.
if (e.amount === 0 && e.source === "shadow upkeep") return null;
const soak = e.soaks?.map((s) => `${s.what} soaks ${s.amount}`).join(", "); const soak = e.soaks?.map((s) => `${s.what} soaks ${s.amount}`).join(", ");
return `${e.player} takes ${e.amount} damage (${e.source}${soak ? `${soak}` : ""}) — ${e.lifeAfter} life left.`; return `${e.player} takes ${e.amount} damage (${e.source}${soak ? `${soak}` : ""}) — ${e.lifeAfter} life left.`;
} }
@@ -126,7 +129,9 @@ export function humanize(e: GameEvent): string | null {
case "cardDisplayed": return `${e.player} displays ${cardDef(e.card.cardId).name}.`; case "cardDisplayed": return `${e.player} displays ${cardDef(e.card.cardId).name}.`;
case "lifeTraded": return `${e.player} burns ${e.points} life for speed!`; case "lifeTraded": return `${e.player} burns ${e.points} life for speed!`;
case "castAroundCorner": return `The spell bends around the corner!`; case "castAroundCorner": return `The spell bends around the corner!`;
case "moveBumped": return `${e.player} blunders into a wall!`; case "moveBumped": return e.why === "pit"
? `${e.player} leaps the pit — nothing to land on beyond it — and teeters back.`
: `${e.player} blunders into a wall!`;
case "attackMisdirected": return e.newTarget case "attackMisdirected": return e.newTarget
? `${e.attacker}'s blind attack veers off — and hits ${e.newTarget}!` ? `${e.attacker}'s blind attack veers off — and hits ${e.newTarget}!`
: `${e.attacker}'s blind attack flies off into the darkness.`; : `${e.attacker}'s blind attack flies off into the darkness.`;