Card wave 4: all eight magic stones and Slow Death

Stones are displayed permanents whose powers weave into the core
systems: BLOODSTONE (-1 to every hit taken, cancels Slow Death's
drip), SOULSTONE (last three points immune to spell damage — punches
and daggers still finish the job), BRAINSTONE (+2 cards now, hand
limit 9 — limits are now per-player and dynamic), POWERSTONE (+1 per
number card played, movement included), SPEEDSTONE (+1 movement,
overridden by SLOW), SHADOWSTONE (physical damage you deal feeds your
life), SHIELDSTONE (number cards become counteractions that shave
their value off damage AND duration), VISIONSTONE (LOS through any
single wall or door, at the holder's option). Damage now carries a
spell/physical kind everywhere. SLOW DEATH is a permanent curse: one
magical point per card drawn, forever — Fireball still burns stones
off their display. Re-displaying a stone is refused (no Brainstone
double-draws). 49 basic-set cards implemented, 15 remain (the hard
five: bent LOS, Blind, Illusion Wall, Ugly, sector manipulation).
72 tests passing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-15 20:17:29 -04:00
co-authored by Claude Fable 5
parent 2d88b4ab40
commit 4ab44cc535
3 changed files with 381 additions and 18 deletions
+215
View File
@@ -0,0 +1,215 @@
import { describe, expect, it } from "vitest";
import {
applyCommand,
activePlayer,
createGame,
displays,
handLimit,
sustainedOn,
type Command,
type GameState,
type PlayerId,
} from "../src/game";
import type { CardInstance } from "../src/cards";
function newGame(seed = 42) {
return createGame({ playerIds: ["alice", "bob"], seed, sets: ["basic"] });
}
function must(state: GameState, player: PlayerId, command: Command): GameState {
const result = applyCommand(state, player, command);
if (!result.ok) throw new Error(`command failed: ${result.error}`);
return result.state;
}
function giveCard(state: GameState, playerId: PlayerId, cardId: string, tag = "T", slot = 0): CardInstance {
const p = state.players.find((p) => p.id === playerId)!;
const instance = { instanceId: `${cardId}#${tag}`, cardId };
p.hand[slot] = instance;
return instance;
}
function toRound2(state: GameState): GameState {
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
return state;
}
function faceOff(state: GameState): { attacker: PlayerId; defender: PlayerId } {
const attacker = activePlayer(state);
const defender = state.players.find((p) => p.id !== attacker.id)!;
defender.position = { ...attacker.position };
return { attacker: attacker.id, defender: defender.id };
}
function castAt(
state: GameState, attacker: PlayerId, defender: PlayerId, card: CardInstance,
extra: Partial<Extract<Command, { type: "cast" }>> = {},
): GameState {
state = must(state, attacker, {
type: "cast", instanceId: card.instanceId,
target: { kind: "player", playerId: defender }, ...extra,
});
return must(state, defender, { type: "pass" });
}
/** 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("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
});
});