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:
co-authored by
Claude Fable 5.1
parent
b92016db51
commit
0750fe3f21
@@ -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);
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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.`;
|
||||||
|
|||||||
Reference in New Issue
Block a user