Server: turnFacts joins actingSeat's full priority order (a hanging ward or slow-death window now reads as the lobby's attention, stubs included), summarize and stubOf share one baseSummary, the stranded doc comment returns to summarize, the protocol contract names feedbackList, Sentry init moves below the imports it cannot precede, randomBytes joins its group, the feedback path hoists once with the operator-fields note. Engine: the safe's sight check trusts castSight alone, the locked-safe predicate becomes lockedSafeAt, the doomed-grab test drives both seats and must reach endTurn. Web: the twin safe handlers merge, paPoints joins the reset ritual with a derived burnable range, five decorative empty handlers and a dead class go, the hand earns real list semantics, fitHandCards cites Card.svelte's tokens, two wrap-sites reindent, feedbackReports moves home as a named type, and the chronicle's stay-mounted reason is stated. feedback-reply.sh adopts its siblings' set/usage conventions and speaks Node. Two test screenshots leave the repo root. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
288 lines
14 KiB
TypeScript
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 (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 });
|
|
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
|
|
});
|
|
});
|