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);