Files
Eric WagonerandClaude Fable 5 afa0e17483 Credibility pass: the fast-edit shavings swept from the ops-and-rev-13 batch
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
2026-09-01 11:04:01 -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 (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
});
});