Files
wizwar6e/packages/engine/test/stones.test.ts
T
Eric WagonerandClaude Fable 5 8cfd911a3e Slow Death's draw is one blow, countered against its total (table ruling)
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-09-01 10:41:47 -04:00

288 lines
14 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { applyCommand, activePlayer, createGame, displays, handLimit, sustainedOn, type GameState, type PlayerId } from "../src/game";
import type { CardInstance } from "../src/cards";
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("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);
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 });
// 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 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(15);
expect(state.players.find((p) => p.id === defender)!.hand.some((c) => c.cardId === "absorb")).toBe(false);
});
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)!;
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 });
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)", () => {
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("a full reflection returns the curse onto its caster (rev 11)", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const sd = giveCard(state, attacker, "slow-death");
giveCard(state, defender, "full-reflection", "FR", 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: "full-reflection#FR" });
state = drain(state);
expect(sustainedOn(state, defender, "slow-death").length).toBe(0);
expect(sustainedOn(state, attacker, "slow-death").length).toBe(1);
// The caster now bleeds for their own draws.
state = must(state, attacker, { type: "endTurn", draw: 1 });
expect(state.players.find((p) => p.id === attacker)!.life).toBe(14);
});
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
});
});