Files
wizwar6e/packages/engine/test/stones.test.ts
T
Eric WagonerandClaude Fable 5 92613d7386 Ops hardening plus two rules gaps close (rev 13)
Operational: auto-reboot for kernel patches (09:30 UTC), Caddy access
logs at /var/lib/caddy (self-rotating; the sandbox denies /var/log),
logrotate for the backup log, a restore drill proving a Spaces snapshot
boots 56/56 rooms clean, the about page's plain sentence on permanent
recording, and /wizwar-pulse to read it all weekly.

Rules: safes now smash — attacks aimed at the box accumulate on it and
the fifteenth point bursts it (widening, ungated). And SLOW DEATH's
bites pause for a victim holding an ABSORB — the card named by the FAQ,
not Absorb Spell — each soaking exactly one point, rev-gated at 13 with
the instant-bite legacy pinned. Bots weigh the soak against their life.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-09-01 10:35:52 -04:00

269 lines
13 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("an absorb in hand pauses the bites; each soaks one point (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; 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 one point; the other lands.
state = must(state, defender, { type: "slowDeathChoice", absorbInstanceId: "absorb#AB" });
expect(state.slowDeathPending).toBeNull();
expect(state.players.find((p) => p.id === defender)!.life).toBe(14);
expect(state.players.find((p) => p.id === defender)!.hand.some((c) => c.cardId === "absorb")).toBe(false);
});
it("without an absorb the bites land instantly, as in older decks", () => {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 12 });
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)!;
giveCard(state, defender, "absorb", "AB", 0);
const toDiscard = d.hand.filter((c) => c.cardId !== "absorb").slice(0, 2).map((c) => c.instanceId);
state = must(state, defender, { type: "discard", instanceIds: toDiscard });
state = must(state, defender, { type: "endTurn", draw: 2 });
// Rev 12: even with an absorb in hand, the old decks bite instantly.
expect(state.slowDeathPending ?? null).toBeNull();
expect(state.players.find((p) => p.id === defender)!.life).toBe(13);
});
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
});
});