From e885325098068cafe208a96c6cb5980db9b126e4 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Sat, 29 Aug 2026 22:20:29 -0400 Subject: [PATCH] Reversed, Slow Death heals a point per draw (rules rev 11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the FAQ: a REVERSE riding the cast turns the whole curse — the victim gains a point for every card drawn, as permanently as the bite. The stack's reversed verdict now reaches onResolved, and the sustained effect carries it in its data scratch. Older ledgers attached the biting curse regardless and replay unchanged (pinned at deckRev 10). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF --- packages/engine/src/game.ts | 34 ++++++++++++++++----- packages/engine/test/stones.test.ts | 46 ++++++++++++++++++++++++++++- 2 files changed, 71 insertions(+), 9 deletions(-) diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index 4b7e81c..0b2f14a 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -233,7 +233,7 @@ export interface CastParams { } /** The revision new games are dealt under; GameConfig.deckRev pins it per game. */ -export const CURRENT_RULES_REV = 10; +export const CURRENT_RULES_REV = 11; export interface GameConfig { playerIds: PlayerId[]; @@ -270,6 +270,8 @@ export interface GameConfig { * Rev 10: BUTT-HEAD's ram is MOVEMENT — the charge walks real * corridors on the turn's legal legs (three plus numbers) and spends * them. + * Rev 11: a REVERSE against SLOW DEATH turns the whole curse (FAQ) — + * the victim gains a point for every card drawn, permanently. */ deckRev?: number; } @@ -864,6 +866,8 @@ interface ResolutionContext { defender: PlayerState; damageDealt: number; fullyStopped: boolean; + /** A surviving REVERSE rode the stack: the spell's effect runs backward. */ + reversed: boolean; duration: number; stack: CastStack; } @@ -1717,9 +1721,14 @@ const CARD_EFFECTS: Record requiresLos: true, baseDamage: () => 0, // "This is permanent. Once SLOW DEATH is on, it can't be turned off." + // Rev 11, per the FAQ: "If SLOW DEATH is REVERSED, the player receives + // a point for every card drawn" — the whole curse attaches backward, + // just as permanently. Older games attached it biting; they replay so. onResolved: (ctx) => { if (ctx.fullyStopped || !ctx.defender.alive) return; - attachSustained(ctx.state, ctx.events, "slow-death", ctx.attacker.id, ctx.defender.id, PERMANENT_TURNS); + const reversed = ctx.reversed && (ctx.state.config.deckRev ?? 1) >= 11; + attachSustained(ctx.state, ctx.events, "slow-death", ctx.attacker.id, ctx.defender.id, + PERMANENT_TURNS, reversed ? { reversed: 1 } : {}); }, }, blind: { kind: "attack", requiresLos: true, baseDamage: () => 0, sustains: true }, @@ -3584,6 +3593,7 @@ function attachSustained( casterId: PlayerId, targetId: PlayerId, turns: number, + data: Record = {}, ): void { const effect: SustainedEffect = { id: `fx-${state.nextEffectId++}`, @@ -3591,7 +3601,7 @@ function attachSustained( casterId, targetId, remainingTurns: Math.max(1, turns), - data: {}, + data, }; state.sustained.push(effect); events.push({ @@ -6016,7 +6026,7 @@ function resolveStack(state: GameState, events: GameEvent[]): void { effect.onResolved({ state, events, attacker: defender, defender: attacker, - damageDealt: 0, fullyStopped: false, duration: 0, + damageDealt: 0, fullyStopped: false, reversed: false, duration: 0, stack: { ...stack, params: { ...stack.params, cardId: chosen } }, }); } @@ -6150,6 +6160,7 @@ function resolveStack(state: GameState, events: GameEvent[]): void { defender, damageDealt, fullyStopped: pipe.fullyStopped, + reversed: pipe.reversed, duration: pipe.duration, stack, }); @@ -6550,11 +6561,18 @@ function doDiscard(prev: GameState, playerId: PlayerId, instanceIds: string[]): return { ok: true, state, events: [{ type: "cardsDiscarded", player: p.id, cards }] }; } -/** SLOW DEATH: "Opponent takes 1 point of magical damage whenever he draws." */ +/** SLOW DEATH: "Opponent takes 1 point of magical damage whenever he draws" — + * or gains one, for a curse that was REVERSED at its casting. */ function applySlowDeathOnDraw(state: GameState, events: GameEvent[], p: PlayerState, cardsDrawn: number): void { - const stacks = sustainedOn(state, p.id, "slow-death").length; - if (stacks === 0 || cardsDrawn === 0 || !p.alive) return; - for (let i = 0; i < cardsDrawn * stacks; i++) { + const curses = sustainedOn(state, p.id, "slow-death"); + if (curses.length === 0 || cardsDrawn === 0 || !p.alive) return; + const blessed = curses.filter((e) => e.data.reversed).length; + if (blessed > 0) { + const amount = cardsDrawn * blessed; + p.life += amount; + events.push({ type: "lifeGained", player: p.id, amount, source: "slow death (reversed)", lifeAfter: p.life }); + } + for (let i = 0; i < cardsDrawn * (curses.length - blessed); i++) { if (!p.alive) break; applyDamage(state, events, p, 1, "slow death", null); } diff --git a/packages/engine/test/stones.test.ts b/packages/engine/test/stones.test.ts index ccd0fb0..f063770 100644 --- a/packages/engine/test/stones.test.ts +++ b/packages/engine/test/stones.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from "vitest"; import { applyCommand, activePlayer, displays, handLimit, sustainedOn, type GameState, type PlayerId } from "../src/game"; import type { CardInstance } from "../src/cards"; -import { newGame, must, giveCard, toRound2, faceOff, castAt } from "./helpers"; +import { createGame } from "../src/game"; +import { newGame, must, drain, giveCard, toRound2, faceOff, castAt } from "./helpers"; /** Display a stone for a player during their turn. */ function displayStone(state: GameState, playerId: PlayerId, stoneId: string, slot = 0): GameState { @@ -147,6 +148,49 @@ describe("slow death", () => { expect(sustainedOn(state, defender, "slow-death").length).toBe(1); }); + it("a reverse at the casting turns the curse: a point gained per draw (rev 11)", () => { + let { state } = newGame(); + state = toRound2(state); + const { attacker, defender } = faceOff(state); + const sd = giveCard(state, attacker, "slow-death"); + giveCard(state, defender, "reverse", "RV", 0); + const r = applyCommand(state, attacker, { + type: "cast", instanceId: sd.instanceId, target: { kind: "player", playerId: defender }, + }); + if (!r.ok) throw new Error(r.error); + state = must(r.state, defender, { type: "counteract", instanceId: "reverse#RV" }); + state = drain(state); + expect(sustainedOn(state, defender, "slow-death").length).toBe(1); + + 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 }); + state = must(state, defender, { type: "endTurn", draw: 2 }); + expect(state.players.find((p) => p.id === defender)!.life).toBe(17); + }); + + it("older decks attach the biting curse through a reverse (rev ≤10)", () => { + let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 10 }); + state = toRound2(state); + const { attacker, defender } = faceOff(state); + const sd = giveCard(state, attacker, "slow-death"); + giveCard(state, defender, "reverse", "RV", 0); + const r = applyCommand(state, attacker, { + type: "cast", instanceId: sd.instanceId, target: { kind: "player", playerId: defender }, + }); + if (!r.ok) throw new Error(r.error); + state = must(r.state, defender, { type: "counteract", instanceId: "reverse#RV" }); + state = drain(state); + + 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 }); + state = must(state, defender, { type: "endTurn", draw: 2 }); + expect(state.players.find((p) => p.id === defender)!.life).toBe(13); + }); + it("bloodstone cancels slow death's per-draw point", () => { let { state } = newGame(); state = toRound2(state);