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
210 lines
9.9 KiB
TypeScript
210 lines
9.9 KiB
TypeScript
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 { 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 {
|
|
const stone = giveCard(state, playerId, stoneId, "S" + stoneId, slot);
|
|
return must(state, playerId, { type: "cast", instanceId: stone.instanceId });
|
|
}
|
|
|
|
describe("magic stones", () => {
|
|
it("bloodstone shaves one point off every hit", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const { attacker, defender } = faceOff(state);
|
|
// Defender displays bloodstone on their own turn first.
|
|
state = must(state, attacker, { type: "endTurn", draw: 0 });
|
|
state = displayStone(state, defender, "bloodstone");
|
|
expect(displays(state.players.find((p) => p.id === defender)!, "bloodstone")).toBe(true);
|
|
state = must(state, defender, { type: "endTurn", draw: 0 });
|
|
|
|
const fb = giveCard(state, attacker, "fireball", "F", 1);
|
|
state = castAt(state, attacker, defender, fb);
|
|
expect(state.players.find((p) => p.id === defender)!.life).toBe(11); // 5-1
|
|
});
|
|
|
|
it("soulstone keeps the last three points safe from spells", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const { attacker, defender } = faceOff(state);
|
|
state = must(state, attacker, { type: "endTurn", draw: 0 });
|
|
state = displayStone(state, defender, "soulstone");
|
|
const d = state.players.find((p) => p.id === defender)!;
|
|
d.life = 5;
|
|
state = must(state, defender, { type: "endTurn", draw: 0 });
|
|
|
|
const fb = giveCard(state, attacker, "fireball", "F", 1);
|
|
state = castAt(state, attacker, defender, fb);
|
|
expect(state.players.find((p) => p.id === defender)!.life).toBe(3); // floored, not 0
|
|
|
|
// But a punch (physical) can finish the job past the floor.
|
|
state = must(state, attacker, { type: "endTurn", draw: 0 });
|
|
state = must(state, defender, { type: "endTurn", draw: 0 });
|
|
state = must(state, attacker, { type: "punch", targetId: defender });
|
|
state = must(state, defender, { type: "pass" });
|
|
expect(state.players.find((p) => p.id === defender)!.life).toBe(2);
|
|
});
|
|
|
|
it("brainstone draws two now and raises the hand limit to nine", () => {
|
|
let { state } = newGame();
|
|
const me = activePlayer(state).id;
|
|
state = displayStone(state, me, "brainstone");
|
|
const p = state.players.find((pl) => pl.id === me)!;
|
|
expect(p.hand.length).toBe(9); // 7 - 1 slot replaced + kept + 2 drawn
|
|
expect(handLimit(p)).toBe(9);
|
|
// Re-displaying is refused (no double draw).
|
|
const again = applyCommand(state, me, { type: "cast", instanceId: "brainstone#Sbrainstone" });
|
|
expect(again.ok).toBe(false);
|
|
});
|
|
|
|
it("powerstone adds one to number cards; speedstone adds movement", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const { attacker, defender } = faceOff(state);
|
|
state = displayStone(state, attacker, "powerstone");
|
|
const lb = giveCard(state, attacker, "lightning-blast", "L", 1);
|
|
giveCard(state, attacker, "number-3", "N", 2);
|
|
state = castAt(state, attacker, defender, lb, { numberInstanceIds: ["number-3#N"] });
|
|
expect(state.players.find((p) => p.id === defender)!.life).toBe(11); // 3+1
|
|
|
|
// Speedstone: allowance 4 on the next turn. (The stunned defender's turn
|
|
// is skipped, so play comes straight back to the attacker.)
|
|
state = displayStone(state, attacker, "speedstone", 1);
|
|
state = must(state, attacker, { type: "endTurn", draw: 0 });
|
|
expect(activePlayer(state).id).toBe(attacker);
|
|
expect(state.turn.movementAllowance).toBe(4);
|
|
});
|
|
|
|
it("shieldstone lets number cards counteract spells", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const { attacker, defender } = faceOff(state);
|
|
state = must(state, attacker, { type: "endTurn", draw: 0 });
|
|
state = displayStone(state, defender, "shieldstone");
|
|
state = must(state, defender, { type: "endTurn", draw: 0 });
|
|
|
|
const fb = giveCard(state, attacker, "fireball", "F", 1);
|
|
giveCard(state, defender, "number-4", "N4", 1);
|
|
state = must(state, attacker, {
|
|
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender },
|
|
});
|
|
state = must(state, defender, { type: "counteract", instanceId: "number-4#N4" });
|
|
state = must(state, attacker, { type: "pass" });
|
|
state = must(state, defender, { type: "pass" });
|
|
expect(state.players.find((p) => p.id === defender)!.life).toBe(14); // 5-4
|
|
});
|
|
|
|
it("shadowstone feeds physical damage back as life", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const { attacker, defender } = faceOff(state);
|
|
state = displayStone(state, attacker, "shadowstone");
|
|
const dagger = giveCard(state, attacker, "dagger", "D", 1);
|
|
state = castAt(state, attacker, defender, dagger);
|
|
expect(state.players.find((p) => p.id === defender)!.life).toBe(12);
|
|
expect(state.players.find((p) => p.id === attacker)!.life).toBe(18); // +3
|
|
});
|
|
|
|
it("fireball burns displayed stones off when damage lands", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const { attacker, defender } = faceOff(state);
|
|
state = must(state, attacker, { type: "endTurn", draw: 0 });
|
|
state = displayStone(state, defender, "speedstone");
|
|
state = must(state, defender, { type: "endTurn", draw: 0 });
|
|
|
|
const fb = giveCard(state, attacker, "fireball", "F", 1);
|
|
state = castAt(state, attacker, defender, fb);
|
|
const d = state.players.find((p) => p.id === defender)!;
|
|
expect(d.hand.some((c) => c.cardId === "speedstone")).toBe(false);
|
|
expect(d.displayed.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("slow death", () => {
|
|
it("bleeds one life per card drawn, forever", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const { attacker, defender } = faceOff(state);
|
|
const sd = giveCard(state, attacker, "slow-death");
|
|
state = castAt(state, attacker, defender, sd);
|
|
expect(sustainedOn(state, defender, "slow-death").length).toBe(1);
|
|
|
|
state = must(state, attacker, { type: "endTurn", draw: 0 });
|
|
// Defender discards two to make room, then draws two: 2 damage.
|
|
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 never expires — many turns later it still bleeds.
|
|
for (let i = 0; i < 4; i++) {
|
|
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
|
}
|
|
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);
|
|
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 });
|
|
|
|
state = displayStone(state, defender, "bloodstone");
|
|
const d = state.players.find((p) => p.id === defender)!;
|
|
const toDiscard = d.hand.filter((c) => c.cardId !== "bloodstone").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(15); // 1-1=0 per draw
|
|
});
|
|
});
|