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>
285 lines
12 KiB
TypeScript
285 lines
12 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { applyCommand, activePlayer, boardView, creatureAt, type GameState, type PlayerId } from "../src/game";
|
|
import { cellKey, SIDES, stepTarget, type Side } from "../src/board";
|
|
import type { CardInstance } from "../src/cards";
|
|
import { newExpansionGame as newGame, must, giveCard, toRound2, emptyNeighborCell } from "./helpers";
|
|
|
|
/** Summon a creature next to its creator (round 2+, consumes the attack). */
|
|
function summon(state: GameState, playerId: PlayerId, kind: string, tag = "S") {
|
|
const me = state.players.find((p) => p.id === playerId)!;
|
|
const spot = emptyNeighborCell(state, me.position);
|
|
const card = giveCard(state, playerId, kind, tag);
|
|
const next = must(state, playerId, {
|
|
type: "cast", instanceId: card.instanceId, target: { kind: "cell", cell: spot.cell },
|
|
});
|
|
return { state: next, at: spot.cell };
|
|
}
|
|
|
|
describe("expansion deck", () => {
|
|
it("basic + expansion1 builds the full 200-card game", () => {
|
|
const { state } = newGame();
|
|
const total = state.deck.length + state.discard.length +
|
|
state.players.reduce((s, p) => s + p.hand.length, 0);
|
|
expect(total).toBe(200);
|
|
});
|
|
});
|
|
|
|
describe("monsters", () => {
|
|
it("summoning uses your attack; the monster moves now but attacks next turn", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const me = activePlayer(state).id;
|
|
const r = summon(state, me, "skeleton");
|
|
state = r.state;
|
|
const skeleton = creatureAt(state, r.at)!;
|
|
expect(skeleton.kind).toBe("skeleton");
|
|
expect(state.turn.attackUsed).toBe(true);
|
|
|
|
// It can move on the creation turn...
|
|
const view = boardView(state);
|
|
for (const side of SIDES) {
|
|
if (stepTarget(view, skeleton.position, side).kind === "step") {
|
|
state = must(state, me, { type: "moveCreature", creatureId: skeleton.id, direction: side });
|
|
break;
|
|
}
|
|
}
|
|
// ...but cannot attack until next turn.
|
|
const other = state.players.find((p) => p.id !== me)!;
|
|
other.position = { ...state.creatures[0]!.position };
|
|
const refused = applyCommand(state, me, {
|
|
type: "creatureAttack", creatureId: state.creatures[0]!.id, targetId: other.id,
|
|
});
|
|
expect(refused.ok).toBe(false);
|
|
|
|
// Next turn: the skeleton punches for 2.
|
|
state = must(state, me, { type: "endTurn", draw: 0 });
|
|
state = must(state, other.id, { type: "endTurn", draw: 0 });
|
|
const victim = state.players.find((p) => p.id !== me)!;
|
|
victim.position = { ...state.creatures[0]!.position };
|
|
state = must(state, me, {
|
|
type: "creatureAttack", creatureId: state.creatures[0]!.id, targetId: victim.id,
|
|
});
|
|
expect(state.players.find((p) => p.id !== me)!.life).toBe(13);
|
|
});
|
|
|
|
it("a monster will not obey the enemy and will not strike its creator", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const me = activePlayer(state).id;
|
|
const r = summon(state, me, "skeleton");
|
|
state = r.state;
|
|
const id = state.creatures[0]!.id;
|
|
const other = state.players.find((p) => p.id !== me)!.id;
|
|
state = must(state, me, { type: "endTurn", draw: 0 });
|
|
expect(applyCommand(state, other, { type: "moveCreature", creatureId: id, direction: "N" }).ok).toBe(false);
|
|
state = must(state, other, { type: "endTurn", draw: 0 });
|
|
const creator = state.players.find((p) => p.id === me)!;
|
|
creator.position = { ...state.creatures[0]!.position };
|
|
expect(applyCommand(state, me, { type: "creatureAttack", creatureId: id, targetId: me }).ok).toBe(false);
|
|
});
|
|
|
|
it("the troll rolls a D4 for damage and regenerates at its creator's turn end", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const me = activePlayer(state).id;
|
|
const r = summon(state, me, "troll");
|
|
state = r.state;
|
|
const troll = state.creatures[0]!;
|
|
|
|
// Hurt the troll, then watch a point come back at end of turn.
|
|
const fb = giveCard(state, me, "fireball", "F", 1);
|
|
// (can't attack again this turn — adrenaline not in play — so wound it via
|
|
// test surgery instead)
|
|
void fb;
|
|
troll.damage = 3;
|
|
state = must(state, me, { type: "endTurn", draw: 0 });
|
|
expect(state.creatures[0]!.damage).toBe(2);
|
|
});
|
|
|
|
it("the wraith slips through one wall per turn and its touch steals cards", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const me = activePlayer(state).id;
|
|
const r = summon(state, me, "wraith");
|
|
state = r.state;
|
|
const wraith = state.creatures[0]!;
|
|
|
|
// Walk it through a wall if one is adjacent.
|
|
const view = boardView(state);
|
|
for (const side of SIDES) {
|
|
const t = stepTarget(view, wraith.position, side);
|
|
if (t.kind === "blocked" && t.by === "wall") {
|
|
const dest = { x: wraith.position.x + (side === "E" ? 1 : side === "W" ? -1 : 0),
|
|
y: wraith.position.y + (side === "S" ? 1 : side === "N" ? -1 : 0) };
|
|
if (!view.cells[cellKey(dest)]) continue;
|
|
state = must(state, me, { type: "moveCreature", creatureId: wraith.id, direction: side });
|
|
expect(state.creatures[0]!.wallPassUsed).toBe(1);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Its touch: 2 damage and a random card lost. March it onto the enemy.
|
|
state = must(state, me, { type: "endTurn", draw: 0 });
|
|
const enemy = state.players.find((p) => p.id !== me)!;
|
|
state = must(state, enemy.id, { type: "endTurn", draw: 0 });
|
|
const w = state.creatures[0]!;
|
|
const enemy2 = state.players.find((p) => p.id !== me)!;
|
|
enemy2.position = { ...w.position };
|
|
// A touch requires the WRAITH to enter the square — step it away and back.
|
|
const view2 = boardView(state);
|
|
for (const side of SIDES) {
|
|
const t = stepTarget(view2, w.position, side);
|
|
if (t.kind === "step") {
|
|
const back: Side = side === "N" ? "S" : side === "S" ? "N" : side === "E" ? "W" : "E";
|
|
state = must(state, me, { type: "moveCreature", creatureId: w.id, direction: side });
|
|
state = must(state, me, { type: "moveCreature", creatureId: w.id, direction: back });
|
|
break;
|
|
}
|
|
}
|
|
const bitten = state.players.find((p) => p.id !== me)!;
|
|
expect(bitten.life).toBe(13);
|
|
expect(bitten.hand.length).toBe(6);
|
|
});
|
|
|
|
it("the fire imp scorches on sight and dies only to water", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const me = activePlayer(state).id;
|
|
const r = summon(state, me, "fire-imp");
|
|
state = r.state;
|
|
const imp = state.creatures[0]!;
|
|
|
|
// Fireball cannot destroy it...
|
|
state = must(state, me, { type: "endTurn", draw: 0 });
|
|
const enemy = state.players.find((p) => p.id !== me)!;
|
|
// (enemy may have been scorched at turn start if in LOS — note life)
|
|
const enemyNow = state.players.find((p) => p.id !== me)!;
|
|
enemyNow.position = { ...imp.position }; // stand at the imp for clear sight
|
|
const fb = giveCard(state, enemy.id, "fireball", "F", 0);
|
|
state = must(state, enemy.id, {
|
|
type: "cast", instanceId: fb.instanceId, target: { kind: "creature", creatureId: imp.id },
|
|
});
|
|
expect(state.creatures.length).toBe(1);
|
|
|
|
// ...but a waterbolt douses it instantly.
|
|
state = must(state, enemy.id, { type: "endTurn", draw: 0 });
|
|
state = must(state, me, { type: "endTurn", draw: 0 });
|
|
state.players.find((p) => p.id !== me)!.position = { ...state.creatures[0]!.position };
|
|
const wb = giveCard(state, enemy.id, "waterbolt", "W", 0);
|
|
state = must(state, enemy.id, {
|
|
type: "cast", instanceId: wb.instanceId, target: { kind: "creature", creatureId: imp.id },
|
|
});
|
|
expect(state.creatures.length).toBe(0);
|
|
});
|
|
|
|
it("the democratic monster is moved by every player", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const me = activePlayer(state).id;
|
|
const r = summon(state, me, "democratic-monster");
|
|
state = r.state;
|
|
const id = state.creatures[0]!.id;
|
|
state = must(state, me, { type: "endTurn", draw: 0 });
|
|
const other = activePlayer(state).id;
|
|
expect(other).not.toBe(me);
|
|
const view = boardView(state);
|
|
for (const side of SIDES) {
|
|
if (stepTarget(view, state.creatures[0]!.position, side).kind === "step") {
|
|
const result = applyCommand(state, other, { type: "moveCreature", creatureId: id, direction: side });
|
|
expect(result.ok).toBe(true);
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
|
|
it("mega-monster doubles a monster's toughness; dispel un-creates it", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const me = activePlayer(state).id;
|
|
const r = summon(state, me, "skeleton");
|
|
state = r.state;
|
|
const id = state.creatures[0]!.id;
|
|
const mm = giveCard(state, me, "mega-monster", "MM", 1);
|
|
state = must(state, me, {
|
|
type: "cast", instanceId: mm.instanceId, target: { kind: "creature", creatureId: id },
|
|
});
|
|
expect(state.creatures[0]!.maxDamage).toBe(8);
|
|
|
|
const dc = giveCard(state, me, "dispel-creation", "DC", 2);
|
|
state = must(state, me, {
|
|
type: "cast", instanceId: dc.instanceId, target: { kind: "cell", cell: state.creatures[0]!.position },
|
|
});
|
|
expect(state.creatures.length).toBe(0);
|
|
});
|
|
|
|
it("monsters vanish when their creator dies", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const me = activePlayer(state).id;
|
|
const r = summon(state, me, "skeleton");
|
|
state = r.state;
|
|
const creator = state.players.find((p) => p.id === me)!;
|
|
const enemy = state.players.find((p) => p.id !== me)!;
|
|
creator.life = 1;
|
|
enemy.position = { ...creator.position };
|
|
state = must(state, me, { type: "endTurn", draw: 0 });
|
|
state = must(state, enemy.id, { type: "punch", targetId: me });
|
|
state = must(state, me, { type: "pass" });
|
|
expect(state.players.find((p) => p.id === me)!.alive).toBe(false);
|
|
expect(state.creatures.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("expansion support cards", () => {
|
|
it("adrenaline allows a second attack in one turn", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const attacker = activePlayer(state);
|
|
const defender = state.players.find((p) => p.id !== attacker.id)!;
|
|
defender.position = { ...attacker.position };
|
|
const adr = giveCard(state, attacker.id, "adrenaline");
|
|
giveCard(state, attacker.id, "number-2", "N", 1);
|
|
state = must(state, attacker.id, {
|
|
type: "cast", instanceId: adr.instanceId, numberInstanceIds: ["number-2#N"],
|
|
});
|
|
const fb1 = giveCard(state, attacker.id, "fireball", "F1", 0);
|
|
state = must(state, attacker.id, { type: "cast", instanceId: fb1.instanceId, target: { kind: "player", playerId: defender.id } });
|
|
state = must(state, defender.id, { type: "pass" });
|
|
const fb2 = giveCard(state, attacker.id, "fireball", "F2", 0);
|
|
state = must(state, attacker.id, { type: "cast", instanceId: fb2.instanceId, target: { kind: "player", playerId: defender.id } });
|
|
state = must(state, defender.id, { type: "pass" });
|
|
expect(state.players.find((p) => p.id === defender.id)!.life).toBe(5);
|
|
// A third is refused.
|
|
const fb3 = giveCard(state, attacker.id, "fireball", "F3", 0);
|
|
expect(applyCommand(state, attacker.id, {
|
|
type: "cast", instanceId: fb3.instanceId, target: { kind: "player", playerId: defender.id },
|
|
}).ok).toBe(false);
|
|
});
|
|
|
|
it("shadow costs a life point per turn and dies to any damage", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const me = activePlayer(state).id;
|
|
const r = summon(state, me, "shadow");
|
|
state = r.state;
|
|
expect(state.creatures[0]!.kind).toBe("shadow");
|
|
const lifeBefore = state.players.find((p) => p.id === me)!.life;
|
|
|
|
// Around to my next turn: upkeep costs 1.
|
|
state = must(state, me, { type: "endTurn", draw: 0 });
|
|
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
|
expect(state.players.find((p) => p.id === me)!.life).toBe(lifeBefore - 1);
|
|
|
|
// Any damage destroys it.
|
|
const enemy = state.players.find((p) => p.id !== me)!.id;
|
|
state = must(state, me, { type: "endTurn", draw: 0 });
|
|
state.players.find((p) => p.id === enemy)!.position = { ...state.creatures[0]!.position };
|
|
const fb = giveCard(state, enemy, "fireball", "F", 0);
|
|
state = must(state, enemy, {
|
|
type: "cast", instanceId: fb.instanceId,
|
|
target: { kind: "creature", creatureId: state.creatures[0]!.id },
|
|
});
|
|
expect(state.creatures.length).toBe(0);
|
|
});
|
|
});
|