Reversed, Slow Death heals a point per draw (rules rev 11)

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
This commit is contained in:
Eric Wagoner
2026-08-29 22:20:29 -04:00
co-authored by Claude Fable 5
parent e930d013f8
commit e885325098
2 changed files with 71 additions and 9 deletions
+26 -8
View File
@@ -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<string, AttackEffect | NeutralEffect | CounterEffect>
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<string, number> = {},
): 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);
}
+45 -1
View File
@@ -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);