A three-reviewer sweep for tells of piecemeal machine generation, every finding verified against the code before touching it. No behavior changes; the full suite passes unchanged (plus two strengthened pins). Engine: removed four void-silenced fossils (a parseEdgeKey call voided where it stood, stoneEffect's ignored cardId parameter, the actualTarget remnant in doCast, a voided loop variable in shadow upkeep); fixed the initialize-then-overwrite narration in spawnCreature; replaced a filter(() => false) no-op; waterwall now rides waveFromEdge instead of carrying its own verbatim copy (and the single-caller washBack wrapper went with it); blind wall-bumps and LOS blockers each collapsed to one implementation; the wand-id list and the "permanent" duration sentinel became named constants; the ambush number local no longer shadows the imported numberValue function; assorted reviewer-aimed phrasings rewritten as the constraints they guard. Server/deploy: the protocol header now documents all eleven message types; dropped an eslint pragma with no eslint, a test script with no tests, and an rsync exclude anchored at a path that never existed (the real data/ dir now excluded); the Caddy vhost has one source of truth; stale "pending DNS" note removed — the record resolves. Web: ~90 lines of CSS swallowed verbatim into a mobile media query deduplicated; the reduced-motion guard on the board now actually stops the marked-cell pulse; one shared color module replaces two drifted palettes; an orphaned doc comment rejoined its function. Tests: the ten-times-pasted helper block became test/helpers.ts; wave-numbered files renamed for the behaviors they pin; deliberation comments and void-ed corpses of unwritten assertions deleted; silent seed-dependent early-returns now fail loudly; one assertion that compared a value to itself now pins the home-translation it meant to; the stored-log single-number command form gained the explicit compatibility test it deserved. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
166 lines
7.7 KiB
TypeScript
166 lines
7.7 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { applyCommand, activePlayer, displays, handLimit, sustainedOn, type GameState, type PlayerId } from "../src/game";
|
|
import type { CardInstance } from "../src/cards";
|
|
import { newGame, must, 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("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
|
|
});
|
|
});
|