From 7bcad12449e3c68e6ec675f13bbb20ce2310c555 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Sat, 29 Aug 2026 23:36:13 -0400 Subject: [PATCH] The ward's bite gets its name, and shrink slips past the giant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two chronicle truths from tonight's table: the cardless ward stack fell through to 'punch from ' — 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 Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF --- packages/engine/src/game.ts | 22 +++++++++++++-------- packages/engine/test/creatures.test.ts | 27 ++++++++++++++++++++++++++ packages/engine/test/game.test.ts | 8 +++++++- 3 files changed, 48 insertions(+), 9 deletions(-) diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index f2e546f..326ffc8 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -215,6 +215,9 @@ export interface CastStack { /** STRENGTH's tear: "this would be an attack" — the grab rides the stack * like a punch, and the clutch roll waits for the counteractions. */ 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 ". */ + sourceName?: string; } export interface CastParams { @@ -4233,12 +4236,15 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult { // Square contents at the destination. let content = state.squareContents[cellKey(p.position)]; if (content?.kind === "stone") return err("that square is solid stone"); - // BIG MAN: nobody enters his square. - for (const other of state.players) { - if (other.id !== p.id && other.alive && cellKey(other.position) === cellKey(p.position) && - sustainedOn(state, other.id, "big-man").length > 0) { - p.position = from; - return err("a giant fills that corridor"); + // 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) { + if (other.id !== p.id && other.alive && cellKey(other.position) === cellKey(p.position) && + sustainedOn(state, other.id, "big-man").length > 0) { + p.position = from; + return err("a giant fills that corridor"); + } } } // BIG MAN pushes: "You can push monsters or other players (in an adjacent @@ -4663,7 +4669,7 @@ function doWardChoice(prev: GameState, play: boolean): CommandResult { // recorded the instant bite and replay it. if ((state.config.deckRev ?? 1) >= 9) { openCardlessStack(state, owner.id, taker.id, { - params: { damage: 3 }, kind: "spell", trapped: true, + params: { damage: 3 }, kind: "spell", trapped: true, sourceName: "warded treasure", }); } else { applyDamage(state, events, taker, 3, "warded treasure", null); @@ -6120,7 +6126,7 @@ function resolveStack(state: GameState, events: GameEvent[]): void { } else if (pipe.damage > 0) { const source = stack.reflectedBase ? `${attackId} (reflected)` - : attackId ?? + : attackId ?? stack.sourceName ?? (attackingCreature ? `${attackingCreature.kind}'s blow` : `punch from ${attacker.id}`); applyDamage(state, events, defender, pipe.damage, source, attacker.id, pipe.kind); damageDealt = pipe.damage; diff --git a/packages/engine/test/creatures.test.ts b/packages/engine/test/creatures.test.ts index ad49119..9d22087 100644 --- a/packages/engine/test/creatures.test.ts +++ b/packages/engine/test/creatures.test.ts @@ -411,6 +411,33 @@ describe("big man", () => { 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", () => { const rig = bigRig(); let state = rig.state; diff --git a/packages/engine/test/game.test.ts b/packages/engine/test/game.test.ts index 401e0a7..878c920 100644 --- a/packages/engine/test/game.test.ts +++ b/packages/engine/test/game.test.ts @@ -292,7 +292,13 @@ describe("the Ward is played in the moment", () => { if (!r.ok) throw new Error(r.error); // The bite rides the stack: a counteraction window opens for the thief. 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.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);