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:
co-authored by
Claude Fable 5
parent
2d88b4ab40
commit
4ab44cc535
@@ -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
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user