From 8cfd911a3e324ffdda66eabdd36fcfd22ea7e0a8 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Tue, 1 Sep 2026 10:41:47 -0400 Subject: [PATCH] Slow Death's draw is one blow, countered against its total (table ruling) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two cards drawn is a two-point blow: ABSORB soaks up to three of the total, BLUNT halves it rounding up (so it finally earns a place — 2 becomes 1), counters may chain, declining takes the rest. The client labels each counter with the exact points it leaves; the bots weigh the total against their floor. Amends rev 13 minutes after it shipped; every production ledger still replays clean. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF --- packages/engine/src/automaton.ts | 15 +++++--- packages/engine/src/game.ts | 58 +++++++++++++++++------------ packages/engine/test/stones.test.ts | 41 ++++++++++++++------ packages/web/src/App.svelte | 24 +++++++----- packages/web/src/net.svelte.ts | 4 +- 5 files changed, 91 insertions(+), 51 deletions(-) diff --git a/packages/engine/src/automaton.ts b/packages/engine/src/automaton.ts index 26c2d61..716c947 100644 --- a/packages/engine/src/automaton.ts +++ b/packages/engine/src/automaton.ts @@ -1276,12 +1276,17 @@ export function automatonCommand( if (view.slowDeathPending) { if (view.slowDeathPending.playerId !== you) return null; - // Soak only when the bites reach toward the floor — the card is worth - // more against a fireball than one point of rot. - const absorb = inHand(view, "absorb"); + // Counter only when the total is worth a card or the floor is near — + // an Absorb is worth more against a fireball than one point of rot. const pts = view.slowDeathPending.points; - if (absorb && me(view).life - pts <= 4) { - return { type: "slowDeathChoice", absorbInstanceId: absorb.instanceId }; + const pressed = me(view).life - pts <= 4; + const absorb = inHand(view, "absorb"); + if (absorb && (pts >= 2 || pressed)) { + return { type: "slowDeathChoice", counterInstanceId: absorb.instanceId }; + } + const blunt = inHand(view, "blunt"); + if (blunt && pts >= 2 && pressed) { + return { type: "slowDeathChoice", counterInstanceId: blunt.instanceId }; } return { type: "slowDeathChoice" }; } diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index 58fd1c2..b0bc6df 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -284,9 +284,10 @@ export interface GameConfig { * number riding the cast fuels it, and numbers or POWER RUN points * played under the dash double; older games doubled only the base * allowance and consumed the rider without effect. - * Rev 13: SLOW DEATH's bites pause for a victim holding ABSORB — each - * absorb soaks exactly one point (FAQ: "only stop the damage incurred - * by one card"); older games bit instantly. + * Rev 13: SLOW DEATH's per-draw bites land as ONE blow that pauses + * for a victim holding ABSORB or BLUNT — countered against the total + * (absorb soaks up to 3, blunt halves rounding up); older games bit + * instantly. */ deckRev?: number; } @@ -304,8 +305,9 @@ export interface GameState { /** A treasure was just grabbed and its owner holds WARD: * the table waits while they choose to play it "at that time" or not. */ wardPending: { ownerId: PlayerId; takerId: PlayerId } | null; - /** SLOW DEATH's bites hang while the victim decides whether an ABSORB - * soaks a point ("only stop the damage incurred by one card"). */ + /** SLOW DEATH's bites hang while the victim weighs counteractions: + * the draw's total is one blow — ABSORB soaks up to three of it, + * BLUNT halves it rounding up (table ruling). */ slowDeathPending: { playerId: PlayerId; points: number } | null; /** CHAOS is landing: each queued player may play FULL SHIELD to sit out. */ chaosPending: { casterId: PlayerId; excluded: PlayerId[]; queue: PlayerId[] } | null; @@ -752,7 +754,7 @@ export type GameEvent = | { type: "madDash"; player: PlayerId; newAllowance: number } | { type: "safeOpened"; player: PlayerId; cell: Cell; withCardId: string } | { type: "slowDeathWindow"; player: PlayerId; points: number } - | { type: "slowDeathAbsorbed"; player: PlayerId; remaining: number } + | { type: "slowDeathCountered"; player: PlayerId; cardId: string; remaining: number } | { type: "safeDamaged"; attacker: PlayerId; cell: Cell; amount: number; total: number } | { type: "safeSmashed"; attacker: PlayerId; cell: Cell } | { type: "trapSprung"; player: PlayerId; cardId?: string } @@ -796,7 +798,7 @@ export type Command = | { type: "testIllusion"; cell: Cell; side: Side } | { type: "armWard" } | { type: "wardChoice"; play: boolean } - | { type: "slowDeathChoice"; absorbInstanceId?: string } + | { type: "slowDeathChoice"; counterInstanceId?: string } | { type: "warpStep" } | { type: "moveCreature"; creatureId: string; direction: Side } | { type: "creatureWarpStep"; creatureId: string } @@ -3901,8 +3903,8 @@ function applyCommandInner(state: GameState, playerId: PlayerId, command: Comman // SLOW DEATH's window: the bites hang while the victim weighs an absorb. if (state.slowDeathPending) { if (playerId !== state.slowDeathPending.playerId) return err("waiting on the slow death's victim"); - if (command.type !== "slowDeathChoice") return err("soak a point with an Absorb or take the bites"); - return doSlowDeathChoice(state, playerId, command.absorbInstanceId); + if (command.type !== "slowDeathChoice") return err("counter the bites or take them"); + return doSlowDeathChoice(state, playerId, command.counterInstanceId); } if (state.pendingDiscard) { @@ -6704,10 +6706,12 @@ function applySlowDeathOnDraw(state: GameState, events: GameEvent[], p: PlayerSt } const bites = cardsDrawn * (curses.length - blessed); if (bites === 0) return; - // Rev 13, per the FAQ: ABSORB "will only stop the damage incurred by one - // card" — one point, one bite. A victim holding an ABSORB gets the choice - // before the bites land. Older games bit instantly and replay so. - if ((state.config.deckRev ?? 1) >= 13 && p.hand.some((c) => c.cardId === "absorb")) { + // Rev 13 (table ruling): the draw's bites land as ONE blow, countered + // against the total — ABSORB soaks up to three, BLUNT halves rounding + // up. A victim holding either gets the window; older games bit + // instantly and replay so. + if ((state.config.deckRev ?? 1) >= 13 && + p.hand.some((c) => c.cardId === "absorb" || c.cardId === "blunt")) { state.slowDeathPending = { playerId: p.id, points: bites }; events.push({ type: "slowDeathWindow", player: p.id, points: bites }); return; @@ -6719,22 +6723,28 @@ function applySlowDeathOnDraw(state: GameState, events: GameEvent[], p: PlayerSt checkVictory(state, events); } -/** The victim's answer to hanging SLOW DEATH bites: each ABSORB soaks - * exactly one point ("the damage incurred by one card" is one bite); - * declining, or running out of absorbs, takes the rest. */ -function doSlowDeathChoice(prev: GameState, playerId: PlayerId, absorbInstanceId?: string): CommandResult { +/** The victim's answer to hanging SLOW DEATH bites. The batch is one + * blow countered against its total: ABSORB soaks up to three points, + * BLUNT halves it rounding up, and counters may chain as they would + * against any attack. Declining takes what remains. */ +function doSlowDeathChoice(prev: GameState, playerId: PlayerId, counterInstanceId?: string): CommandResult { const state = clone(prev); const pending = state.slowDeathPending!; const p = state.players.find((q) => q.id === playerId)!; const events: GameEvent[] = []; - if (absorbInstanceId) { - const card = p.hand.find((c) => c.instanceId === absorbInstanceId); - if (!card || card.cardId !== "absorb") return err("that is not an Absorb in your hand"); - takeFromHand(p, absorbInstanceId); + if (counterInstanceId) { + const card = p.hand.find((c) => c.instanceId === counterInstanceId); + if (!card || (card.cardId !== "absorb" && card.cardId !== "blunt")) { + return err("only an Absorb or a Blunt answers the rot"); + } + takeFromHand(p, counterInstanceId); state.discard.push(card); - pending.points -= 1; - events.push({ type: "slowDeathAbsorbed", player: p.id, remaining: pending.points }); - if (pending.points > 0 && p.hand.some((c) => c.cardId === "absorb")) { + pending.points = card.cardId === "absorb" + ? Math.max(0, pending.points - 3) + : Math.ceil(pending.points / 2); + events.push({ type: "slowDeathCountered", player: p.id, cardId: card.cardId, remaining: pending.points }); + if (pending.points > 0 && + p.hand.some((c) => c.cardId === "absorb" || c.cardId === "blunt")) { return { ok: true, state, events }; } } diff --git a/packages/engine/test/stones.test.ts b/packages/engine/test/stones.test.ts index 3db0e61..0486af7 100644 --- a/packages/engine/test/stones.test.ts +++ b/packages/engine/test/stones.test.ts @@ -147,7 +147,7 @@ describe("slow death", () => { expect(sustainedOn(state, defender, "slow-death").length).toBe(1); }); - it("an absorb in hand pauses the bites; each soaks one point (rev 13)", () => { + it("the draw's bites land as one blow: an absorb soaks up to three (rev 13)", () => { let { state } = newGame(); state = toRound2(state); const { attacker, defender } = faceOff(state); @@ -159,31 +159,50 @@ describe("slow death", () => { state = must(state, defender, { type: "discard", instanceIds: toDiscard }); giveCard(state, defender, "absorb", "AB", 0); state = must(state, defender, { type: "endTurn", draw: 2 }); - // Two bites hang; nobody else may act. + // Two bites hang as one blow; nobody else may act. expect(state.slowDeathPending).toEqual({ playerId: defender, points: 2 }); expect(applyCommand(state, attacker, { type: "endTurn", draw: 0 }).ok).toBe(false); - // One absorb soaks one point; the other lands. - state = must(state, defender, { type: "slowDeathChoice", absorbInstanceId: "absorb#AB" }); + // One absorb soaks the whole total (up to three). + state = must(state, defender, { type: "slowDeathChoice", counterInstanceId: "absorb#AB" }); expect(state.slowDeathPending).toBeNull(); - expect(state.players.find((p) => p.id === defender)!.life).toBe(14); + expect(state.players.find((p) => p.id === defender)!.life).toBe(15); expect(state.players.find((p) => p.id === defender)!.hand.some((c) => c.cardId === "absorb")).toBe(false); }); - it("without an absorb the bites land instantly, as in older decks", () => { - let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 12 }); + it("a blunt halves the blow's total, rounding up (rev 13)", () => { + let { state } = newGame(); state = toRound2(state); const { attacker, defender } = faceOff(state); const sd = giveCard(state, attacker, "slow-death"); state = castAt(state, attacker, defender, sd); state = must(state, attacker, { type: "endTurn", draw: 0 }); const d = state.players.find((p) => p.id === defender)!; - giveCard(state, defender, "absorb", "AB", 0); - const toDiscard = d.hand.filter((c) => c.cardId !== "absorb").slice(0, 2).map((c) => c.instanceId); + const toDiscard = d.hand.slice(0, 2).map((c) => c.instanceId); state = must(state, defender, { type: "discard", instanceIds: toDiscard }); + giveCard(state, defender, "blunt", "BL", 0); state = must(state, defender, { type: "endTurn", draw: 2 }); - // Rev 12: even with an absorb in hand, the old decks bite instantly. - expect(state.slowDeathPending ?? null).toBeNull(); + expect(state.slowDeathPending).toEqual({ playerId: defender, points: 2 }); + state = must(state, defender, { type: "slowDeathChoice", counterInstanceId: "blunt#BL" }); + // ceil(2 / 2) = 1 lands. + expect(state.slowDeathPending).toBeNull(); + expect(state.players.find((p) => p.id === defender)!.life).toBe(14); + }); + + it("declining the window takes the whole total", () => { + let { state } = newGame(); + state = toRound2(state); + const { attacker, defender } = faceOff(state); + const sd = giveCard(state, attacker, "slow-death"); + state = castAt(state, attacker, defender, sd); + state = must(state, attacker, { type: "endTurn", draw: 0 }); + const d = state.players.find((p) => p.id === defender)!; + const toDiscard = d.hand.slice(0, 2).map((c) => c.instanceId); + state = must(state, defender, { type: "discard", instanceIds: toDiscard }); + giveCard(state, defender, "absorb", "AB", 0); + state = must(state, defender, { type: "endTurn", draw: 2 }); + state = must(state, defender, { type: "slowDeathChoice" }); expect(state.players.find((p) => p.id === defender)!.life).toBe(13); + expect(state.players.find((p) => p.id === defender)!.hand.some((c) => c.cardId === "absorb")).toBe(true); }); it("a reverse at the casting turns the curse: a point gained per draw (rev 11)", () => { diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 1970894..19e88da 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -1563,23 +1563,29 @@ {/if} {#if view?.slowDeathPending && view.slowDeathPending.playerId === view.you} - {@const sdAbsorbs = view.yourHand.filter((c) => c.cardId === "absorb")} + {@const sdPoints = view.slowDeathPending.points} + {@const sdAbsorb = view.yourHand.find((c) => c.cardId === "absorb")} + {@const sdBlunt = view.yourHand.find((c) => c.cardId === "blunt")}
-
Slow Death bites for {view.slowDeathPending.points}!
+
Slow Death bites for {sdPoints}!
- "ABSORB against SLOW DEATH will only stop the damage incurred by - one card" — each Absorb you spend soaks exactly one point. + The draw's bites land as one blow — counter the total: Absorb + soaks up to three points, Blunt halves it (fractions round up).
- {#if sdAbsorbs.length > 0} - {@const firstAbsorb = sdAbsorbs[0]!} + {#if sdAbsorb} + onclick={() => dispatch({ type: "slowDeathChoice", counterInstanceId: sdAbsorb.instanceId })}> + Absorb — take {Math.max(0, sdPoints - 3)} instead + {/if} + {#if sdBlunt} + {/if} + take {sdPoints === 1 ? "the point" : `all ${sdPoints}`}
diff --git a/packages/web/src/net.svelte.ts b/packages/web/src/net.svelte.ts index 131b46a..55c30a2 100644 --- a/packages/web/src/net.svelte.ts +++ b/packages/web/src/net.svelte.ts @@ -82,8 +82,8 @@ export function humanize(e: GameEvent): string | null { case "safeOpened": return `${e.player}'s ${cardDef(e.withCardId).name} clicks the safe open — until turn's end.`; case "safeDamaged": return `${e.attacker} batters the safe — ${e.amount} damage (${e.total}/15).`; case "safeSmashed": return `💥 The safe BURSTS open under ${e.attacker}'s assault!`; - case "slowDeathWindow": return `Slow Death bites ${e.player} for ${e.points} — an Absorb hovers over the wound…`; - case "slowDeathAbsorbed": return `${e.player}'s Absorb soaks one point of the rot (${e.remaining} to go).`; + case "slowDeathWindow": return `Slow Death bites ${e.player} for ${e.points} — a counter hovers over the wound…`; + case "slowDeathCountered": return `${e.player}'s ${cardDef(e.cardId).name} blunts the rot — ${e.remaining} point${e.remaining === 1 ? "" : "s"} still coming.`; case "spellSustained": return `${spellName(e.cardId)} settles over ${e.target} (${e.turns} turn${e.turns === 1 ? "" : "s"}).`; case "spellExpired": return `${spellName(e.cardId)} wears off ${e.target}.`; case "teleported": return e.by === e.player