The ward's bite gets its name, and shrink slips past the giant

Two chronicle truths from tonight's table: the cardless ward stack fell
through to 'punch from <owner>' — stacks now carry a sourceName, and the
bite reads 'warded treasure' as it always did pre-rev-9. And a SHRUNK
wizard walks straight through BIG MAN's square (table ruling: small
enough to slip between the giant's boots) — a widening, shipped ungated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
This commit is contained in:
Eric Wagoner
2026-08-29 23:36:13 -04:00
co-authored by Claude Fable 5
parent d334b267a2
commit 7bcad12449
3 changed files with 48 additions and 9 deletions
+9 -3
View File
@@ -215,6 +215,9 @@ export interface CastStack {
/** STRENGTH's tear: "this would be an attack" the grab rides the stack /** STRENGTH's tear: "this would be an attack" the grab rides the stack
* like a punch, and the clutch roll waits for the counteractions. */ * like a punch, and the clutch roll waits for the counteractions. */
tearTreasure?: true; tearTreasure?: true;
/** What a cardless stack's blow is called in the chronicle (the WARD's
* bite); without it the source falls back to "punch from <attacker>". */
sourceName?: string;
} }
export interface CastParams { export interface CastParams {
@@ -4233,7 +4236,9 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult {
// Square contents at the destination. // Square contents at the destination.
let content = state.squareContents[cellKey(p.position)]; let content = state.squareContents[cellKey(p.position)];
if (content?.kind === "stone") return err("that square is solid stone"); if (content?.kind === "stone") return err("that square is solid stone");
// BIG MAN: nobody enters his square. // BIG MAN: nobody enters his square — except a SHRUNK wizard, small
// enough to slip between the giant's boots (table ruling).
if (sustainedOn(state, p.id, "shrink").length === 0) {
for (const other of state.players) { for (const other of state.players) {
if (other.id !== p.id && other.alive && cellKey(other.position) === cellKey(p.position) && if (other.id !== p.id && other.alive && cellKey(other.position) === cellKey(p.position) &&
sustainedOn(state, other.id, "big-man").length > 0) { sustainedOn(state, other.id, "big-man").length > 0) {
@@ -4241,6 +4246,7 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult {
return err("a giant fills that corridor"); return err("a giant fills that corridor");
} }
} }
}
// BIG MAN pushes: "You can push monsters or other players (in an adjacent // BIG MAN pushes: "You can push monsters or other players (in an adjacent
// square) down the corridor ahead of you as you move". No // square) down the corridor ahead of you as you move". No
// room to shove them onward means no way forward for the giant either. // room to shove them onward means no way forward for the giant either.
@@ -4663,7 +4669,7 @@ function doWardChoice(prev: GameState, play: boolean): CommandResult {
// recorded the instant bite and replay it. // recorded the instant bite and replay it.
if ((state.config.deckRev ?? 1) >= 9) { if ((state.config.deckRev ?? 1) >= 9) {
openCardlessStack(state, owner.id, taker.id, { openCardlessStack(state, owner.id, taker.id, {
params: { damage: 3 }, kind: "spell", trapped: true, params: { damage: 3 }, kind: "spell", trapped: true, sourceName: "warded treasure",
}); });
} else { } else {
applyDamage(state, events, taker, 3, "warded treasure", null); applyDamage(state, events, taker, 3, "warded treasure", null);
@@ -6120,7 +6126,7 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
} else if (pipe.damage > 0) { } else if (pipe.damage > 0) {
const source = stack.reflectedBase const source = stack.reflectedBase
? `${attackId} (reflected)` ? `${attackId} (reflected)`
: attackId ?? : attackId ?? stack.sourceName ??
(attackingCreature ? `${attackingCreature.kind}'s blow` : `punch from ${attacker.id}`); (attackingCreature ? `${attackingCreature.kind}'s blow` : `punch from ${attacker.id}`);
applyDamage(state, events, defender, pipe.damage, source, attacker.id, pipe.kind); applyDamage(state, events, defender, pipe.damage, source, attacker.id, pipe.kind);
damageDealt = pipe.damage; damageDealt = pipe.damage;
+27
View File
@@ -411,6 +411,33 @@ describe("big man", () => {
throw new Error("setup: seed offered no walled pocket beside the home"); throw new Error("setup: seed offered no walled pocket beside the home");
}); });
it("a shrunk wizard slips between the giant's boots", () => {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
state = toRound2(state);
const mover = activePlayer(state);
const giant = state.players.find((p) => p.id !== mover.id)!;
state.sustained.push({
id: "fx-big", cardId: "big-man", casterId: giant.id, targetId: giant.id,
remainingTurns: 9, data: {},
});
const view = boardView(state);
const side = SIDES.find((s) => stepTarget(view, mover.position, s).kind === "step");
if (!side) throw new Error("setup: no open step beside the mover");
const t = stepTarget(view, mover.position, side);
if (t.kind !== "step") throw new Error("unreachable");
giant.position = { ...t.to };
// Full-size, the corridor is filled.
expect(applyCommand(state, mover.id, { type: "move", direction: side }).ok).toBe(false);
// Shrunk, the same step slips through.
state.sustained.push({
id: "fx-small", cardId: "shrink", casterId: mover.id, targetId: mover.id,
remainingTurns: 1, data: {},
});
const r = applyCommand(state, mover.id, { type: "move", direction: side });
if (!r.ok) throw new Error(r.error);
expect(cellKey(r.state.players.find((p) => p.id === mover.id)!.position)).toBe(cellKey(giant.position));
});
it("monsters cannot enter the giant's square", () => { it("monsters cannot enter the giant's square", () => {
const rig = bigRig(); const rig = bigRig();
let state = rig.state; let state = rig.state;
+7 -1
View File
@@ -292,7 +292,13 @@ describe("the Ward is played in the moment", () => {
if (!r.ok) throw new Error(r.error); if (!r.ok) throw new Error(r.error);
// The bite rides the stack: a counteraction window opens for the thief. // The bite rides the stack: a counteraction window opens for the thief.
expect(r.state.stack).toBeTruthy(); expect(r.state.stack).toBeTruthy();
state = drain(r.state); const bite = applyCommand(r.state, r.state.stack!.waitingOn, { type: "pass" });
if (!bite.ok) throw new Error(bite.error);
// The chronicle names the bite for what it is, not a punch.
expect(bite.events.some(
(e) => e.type === "damaged" && e.source === "warded treasure",
)).toBe(true);
state = drain(bite.state);
expect(state.wardPending).toBeNull(); expect(state.wardPending).toBeNull();
expect(state.players.find((p) => p.id === "thief")!.life).toBe(12); expect(state.players.find((p) => p.id === "thief")!.life).toBe(12);
expect(state.players.find((p) => p.id === "owner")!.hand.some((c) => c.cardId === "ward")).toBe(false); expect(state.players.find((p) => p.id === "owner")!.hand.some((c) => c.cardId === "ward")).toBe(false);