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
* 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,
from: Cell,
goals: Set<string>,
@@ -607,6 +616,11 @@ function pathToward(
const k = cellKey(to);
if (seen.has(k)) 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);
cameBy.set(k, { prev: cellKey(c), dir, viaDoor });
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
// road at all (throughJails) dives in. "Few will dive into one
// unless they are desperate."
const hazard = view.squareContents[k]?.kind;
if (hazard === "thornbush" && !opts.throughJails) continue;
if (!opts.throughHazards &&
(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
* shed (good counters and attacks are hoarded, as a human would). */
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) {
const shed = [...view.yourHand]
.filter((c) => discardValue(c, view, style) <= 3)
@@ -734,7 +748,16 @@ function endTurnDrawing(view: GameView, tier: TierTraits, style?: AutomatonStyle
.map((c) => c.instanceId);
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[] {
@@ -1522,8 +1545,13 @@ export function automatonCommand(
}
}
}
// No shot at a wizard: raise a creature to do the walking.
const summon = view.yourHand.find((c) => SUMMONS.has(c.cardId));
// No shot at a wizard: raise a creature to do the walking. A SHADOW
// 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) {
const near = style === "worrier" ? self.position : livingEnemies(view)[0]!.position;
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: "spellReused"; player: PlayerId; card: CardInstance }
| { 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: "dieRolled"; player: PlayerId | null; roll: number; purpose: 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.
p.position = from;
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 };
}
}