Credibility pass: one voice, no scars

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>
This commit is contained in:
Eric Wagoner
2026-08-16 11:56:55 -04:00
co-authored by Claude Fable 5
parent f62fcf2510
commit 695307daa8
24 changed files with 240 additions and 716 deletions
+19 -43
View File
@@ -1,50 +1,12 @@
import { describe, expect, it } from "vitest";
import {
applyCommand,
activePlayer,
createGame,
boardView,
type Command,
type GameState,
type PlayerId,
} from "../src/game";
import { applyCommand, activePlayer, boardView } from "../src/game";
import { cellKey, edgeKey, hasLineOfSight, neighbor, type Side } from "../src/board";
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;
}
import { newGame, must, giveCard, toRound2, faceOff } from "./helpers";
/** Test surgery: put a specific card into a player's hand (swapping one out). */
function giveCard(state: GameState, playerId: PlayerId, cardId: string, tag = "T"): CardInstance {
const p = state.players.find((p) => p.id === playerId)!;
const instance = { instanceId: `${cardId}#${tag}`, cardId };
p.hand[0] = instance;
return instance;
}
/** Advance past round 1 (both players just end their turns). */
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 });
expect(state.turn.round).toBe(2);
return state;
}
/** Put attacker and defender in mutual LOS (same square works for spells too). */
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 };
}
describe("attack spells", () => {
it("fireball does 5 flat damage when unopposed", () => {
let { state } = newGame();
@@ -174,9 +136,7 @@ describe("attack spells", () => {
state = toRound2(state);
const attacker = activePlayer(state);
const defender = state.players.find((p) => p.id !== attacker.id)!;
// Stand defender 1 east of attacker with open space behind (find a spot):
// use same square then nudge — simplest robust arrangement: same square,
// knockback direction defaults to none, so instead place east if open.
// Same square: waterbolt needs a legal target; knockback is the engine's problem.
defender.position = { ...attacker.position };
const wb = giveCard(state, attacker.id, "waterbolt");
const a = state.players.find((p) => p.id === attacker.id)!;
@@ -338,3 +298,19 @@ describe("the post-game reveal", () => {
expect(fallen.map((c) => c.cardId).sort()).toEqual(["fireball", "number-6"]);
});
});
describe("stored-log compatibility", () => {
it("replays the single-number command form older logs contain", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const fb = giveCard(state, attacker, "fireball");
giveCard(state, attacker, "number-3", "N", 1);
state = must(state, attacker, {
type: "cast", instanceId: fb.instanceId, numberInstanceId: "number-3#N",
target: { kind: "player", playerId: defender },
});
state = must(state, defender, { type: "pass" });
expect(state.players.find((p) => p.id === defender)!.life).toBe(15 - 5); // fireball: 2 + the 3
});
});
+4 -53
View File
@@ -1,54 +1,8 @@
import { describe, expect, it } from "vitest";
import {
applyCommand,
activePlayer,
createGame,
boardView,
creatureAt,
gameLos,
type Command,
type GameState,
type PlayerId,
} from "../src/game";
import { cellKey, SIDES, stepTarget, type Cell, type Side } from "../src/board";
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";
function newGame(seed = 42) {
return createGame({ playerIds: ["alice", "bob"], seed, sets: ["basic", "expansion1"] });
}
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 emptyNeighborCell(state: GameState, of: Cell): { cell: Cell; side: Side } {
const view = boardView(state);
for (const side of SIDES) {
const t = stepTarget(view, of, side);
if (t.kind !== "step") continue;
const key = cellKey(t.to);
if (view.homes.some((h) => cellKey(h) === key)) continue;
if (state.treasures.some((tr) => tr.position && cellKey(tr.position) === key)) continue;
if (state.players.some((p) => cellKey(p.position) === key)) continue;
return { cell: t.to, side };
}
throw new Error("no empty neighbor");
}
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") {
@@ -171,8 +125,7 @@ describe("monsters", () => {
const w = state.creatures[0]!;
const enemy2 = state.players.find((p) => p.id !== me)!;
enemy2.position = { ...w.position };
// step the wraith one cell and back onto the enemy? Simply move enemy onto
// wraith is not a touch (wraith must enter). Move wraith away then back.
// 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);
@@ -200,8 +153,6 @@ describe("monsters", () => {
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 enemyLife = enemy.life;
void enemyLife;
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);
@@ -1,57 +1,8 @@
import { describe, expect, it } from "vitest";
import {
applyCommand,
activePlayer,
createGame,
boardView,
sustainedOn,
type Command,
type GameState,
type PlayerId,
} from "../src/game";
import { applyCommand, activePlayer, boardView, sustainedOn, type GameState } from "../src/game";
import { cellKey, edgeKey, neighbor, type Side } from "../src/board";
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" });
}
import { newGame, must, giveCard, toRound2, faceOff, castAt } from "./helpers";
describe("duration spells", () => {
it("slow reduces movement to 1, blocks number cards, and halves attacks", () => {
@@ -113,8 +64,6 @@ describe("duration spells", () => {
state = must(state, attacker, { type: "endTurn", draw: 0 });
expect(activePlayer(state).id).toBe(defender);
expect(applyCommand(state, defender, { type: "move", direction: "N" }).ok).toBe(false);
const counter = giveCard(state, defender, "blunt", "B", 0);
void counter;
state = must(state, defender, { type: "endTurn", draw: 0 });
// Attacker punches the frozen defender: no damage.
@@ -276,7 +225,7 @@ describe("movement spells", () => {
break;
}
}
if (!dir) return; // no adjacent wall on this seed's home; fine
if (!dir) throw new Error("setup: seed 42 lost its adjacent wall");
const ptw = giveCard(state, me.id, "pass-through-wall");
state = must(state, me.id, { type: "cast", instanceId: ptw.instanceId });
state = must(state, me.id, { type: "move", direction: dir });
+6 -55
View File
@@ -1,58 +1,8 @@
import { describe, expect, it } from "vitest";
import {
applyCommand,
activePlayer,
boardView,
createGame,
gameLos,
sustainedOn,
type Command,
type GameState,
type PlayerId,
} from "../src/game";
import { applyCommand, activePlayer, boardView, gameLos, sustainedOn } from "../src/game";
import { cellKey, stepTarget } from "../src/board";
import type { CardInstance } from "../src/cards";
function newGame(seed = 42) {
return createGame({ playerIds: ["alice", "bob"], seed, sets: ["basic", "expansion1"] });
}
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" });
}
import { newExpansionGame as newGame, must, giveCard, toRound2, faceOff, castAt } from "./helpers";
describe("expansion combat cards", () => {
it("power attack burns life for extra damage", () => {
@@ -104,9 +54,8 @@ describe("expansion combat cards", () => {
if (r.ok) { state = r.state; steps++; }
if (steps === 2) break;
}
if (steps === 2) {
expect(state.players.find((p) => p.id === defender)!.life).toBe(lifeStart - 1);
}
expect(steps).toBe(2);
expect(state.players.find((p) => p.id === defender)!.life).toBe(lifeStart - 1);
});
it("mental swap trades entire hands", () => {
@@ -208,6 +157,8 @@ describe("expansion combat cards", () => {
if (r.ok) {
const after = r.state.players.find((p) => p.id === defender)!.position;
expect(cellKey(after)).not.toBe(cellKey(before));
} else {
expect(r.error).toMatch(/blocked/);
}
});
});
@@ -1,47 +1,8 @@
import { describe, expect, it } from "vitest";
import {
applyCommand,
activePlayer,
createGame,
boardView,
gameLos,
type Command,
type GameState,
type PlayerId,
} from "../src/game";
import { cellKey, SIDES, stepTarget, type Cell, type Side } from "../src/board";
import { applyCommand, activePlayer, boardView, gameLos } from "../src/game";
import { cellKey, SIDES, stepTarget, type Cell } from "../src/board";
import type { CardInstance } from "../src/cards";
function newGame(seed = 42) {
return createGame({ playerIds: ["alice", "bob"], seed, sets: ["basic", "expansion1"] });
}
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 emptyNeighborCell(state: GameState, of: Cell): { cell: Cell; side: Side } {
const view = boardView(state);
for (const side of SIDES) {
const t = stepTarget(view, of, side);
if (t.kind !== "step") continue;
const key = cellKey(t.to);
if (view.homes.some((h) => cellKey(h) === key)) continue;
if (state.treasures.some((tr) => tr.position && cellKey(tr.position) === key)) continue;
if (state.players.some((p) => cellKey(p.position) === key)) continue;
return { cell: t.to, side };
}
throw new Error("no empty neighbor");
}
import { newExpansionGame as newGame, must, giveCard, emptyNeighborCell } from "./helpers";
describe("expansion terrain", () => {
it("killer ooze burns on entry and can drop you on your face", () => {
@@ -174,16 +135,14 @@ describe("expansion terrain", () => {
const t = stepTarget(view, from, side);
if (t.kind === "step" && cellKey(t.to) === cellKey(open[0]!)) {
e.position = from;
const result = applyCommand(state, enemy.id, { type: "move", direction: side });
if (result.ok) {
state = result.state;
const hurt = state.players.find((p) => p.id === enemy.id)!;
expect(hurt.life).toBeLessThanOrEqual(11);
expect(state.boobytraps.length).toBe(0);
}
state = must(state, enemy.id, { type: "move", direction: side });
const hurt = state.players.find((p) => p.id === enemy.id)!;
expect(hurt.life).toBeLessThanOrEqual(11);
expect(state.boobytraps.length).toBe(0);
return;
}
}
throw new Error("setup: seed 42 offers no approach to the trap");
});
it("stone to water melts a stone block into a crashing wave", () => {
@@ -199,8 +158,6 @@ describe("expansion terrain", () => {
type: "cast", instanceId: stw.instanceId, target: { kind: "cell", cell: spot.cell },
});
expect(state.squareContents[cellKey(spot.cell)]).toBeUndefined();
// The caster stood beside the block: the wave washed them somewhere (or
// crushed them for blocked spaces) — either way life or position changed
// is acceptable; assert no crash and the block is gone.
// Wave side effects vary by geometry; the melt itself is the pinned behavior.
});
});
+2 -3
View File
@@ -218,9 +218,8 @@ describe("treasures and victory", () => {
const [t1, t2] = state.treasures.filter((t) => t.owner === enemy.id);
me.position = { ...t1!.position! };
const s1 = must(state, me.id, { type: "pickUpTreasure" });
const p = s1.players.find((p) => p.id === me.id)!;
p.position = { ...t2!.position! };
p; // actions ended by pickup — but even without that, a second pickup is illegal:
// Stand on the second treasure: the pickup is still refused ("one at a time").
s1.players.find((p) => p.id === me.id)!.position = { ...t2!.position! };
const result = applyCommand(s1, me.id, { type: "pickUpTreasure" });
expect(result.ok).toBe(false);
});
+78
View File
@@ -0,0 +1,78 @@
// Shared test rig: a deterministic two-wizard game plus the moves every
// suite makes — force a card into a hand, burn the no-combat first round,
// stand two wizards face to face, cast-and-let-resolve.
import {
applyCommand,
activePlayer,
boardView,
createGame,
type Command,
type GameState,
type PlayerId,
} from "../src/game";
import { cellKey, SIDES, stepTarget, type Cell, type Side } from "../src/board";
import type { CardInstance } from "../src/cards";
export function newGame(seed = 42, players: PlayerId[] = ["alice", "bob"]) {
return createGame({ playerIds: players, seed, sets: ["basic"] });
}
export function newExpansionGame(seed = 42, players: PlayerId[] = ["alice", "bob"]) {
return createGame({ playerIds: players, seed, sets: ["basic", "expansion1"] });
}
export 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;
}
export 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;
}
/** Burn the no-combat first round: both players pass their opening turn. */
export 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;
}
/** Stand the other wizard on the active one's square. */
export 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 };
}
/** Cast at a player and let it resolve uncountered. */
export 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" });
}
/** An adjacent, walkable square holding no home, treasure, or wizard. */
export function emptyNeighborCell(state: GameState, of: Cell): { cell: Cell; side: Side } {
const view = boardView(state);
for (const side of SIDES) {
const t = stepTarget(view, of, side);
if (t.kind !== "step") continue;
const key = cellKey(t.to);
if (view.homes.some((h) => cellKey(h) === key)) continue;
if (state.treasures.some((tr) => tr.position && cellKey(tr.position) === key)) continue;
if (state.players.some((p) => cellKey(p.position) === key)) continue;
return { cell: t.to, side };
}
throw new Error("no empty neighbor");
}
@@ -1,55 +1,8 @@
import { describe, expect, it } from "vitest";
import {
applyCommand,
activePlayer,
createGame,
boardView,
gameLos,
sustainedOn,
viewFor,
type Command,
type GameState,
type PlayerId,
} from "../src";
import { cellKey, edgeKey, neighbor, SIDES, stepTarget, type Cell, type Side } from "../src/board";
import { applyCommand, activePlayer, boardView, gameLos, sustainedOn, viewFor } from "../src";
import { cellKey, edgeKey, type Cell } from "../src/board";
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 emptyNeighborCell(state: GameState, of: Cell): { cell: Cell; side: Side } {
const view = boardView(state);
for (const side of SIDES) {
const t = stepTarget(view, of, side);
if (t.kind !== "step") continue;
const key = cellKey(t.to);
if (view.homes.some((h) => cellKey(h) === key)) continue;
if (state.treasures.some((tr) => tr.position && cellKey(tr.position) === key)) continue;
if (state.players.some((p) => cellKey(p.position) === key)) continue;
return { cell: t.to, side };
}
throw new Error("no empty neighbor");
}
import { newGame, must, giveCard, toRound2, emptyNeighborCell } from "./helpers";
describe("around the corner", () => {
it("bends line of sight past a wall that blocks a straight cast", () => {
@@ -138,15 +91,13 @@ describe("blind", () => {
});
expect(result.ok).toBe(true);
if (!result.ok) continue;
const dLife = result.state.players.find((p) => p.id === defender.id)!;
if (result.state.stack) {
hits++; // the roll matched: attack proceeds normally
} else {
misses++;
expect(result.state.turn.attackUsed).toBe(true); // card spent anyway
}
void dLife;
}
}
expect(hits + misses).toBe(10);
expect(misses).toBeGreaterThan(0);
});
@@ -230,7 +181,6 @@ describe("sector manipulation", () => {
);
const wallsBefore = Object.values(boardView(state).edges).filter((e) => e === "wall").length;
const homeBefore = { ...me.home };
const treasuresBefore = state.treasures.filter((t) => t.owner === me.id).map((t) => ({ ...t.position! }));
const rs = giveCard(state, me.id, "rotate-sector");
state = must(state, me.id, {
@@ -240,8 +190,7 @@ describe("sector manipulation", () => {
const after = state.players.find((p) => p.id === me.id)!;
// Home star is the exact center: rotation cannot move it.
expect(cellKey(after.home)).toBe(cellKey(homeBefore));
// The wizard stood on the home (center) at setup? They may have been
// anywhere; either way they remain inside the same sector.
// Wherever they stood, rotation keeps them inside the sector.
const p = state.board.placements[idx]!;
expect(after.position.x).toBeGreaterThanOrEqual(p.origin.x);
expect(after.position.x).toBeLessThan(p.origin.x + 5);
@@ -253,7 +202,6 @@ describe("sector manipulation", () => {
for (const t of state.treasures.filter((t) => t.owner === after.id)) {
expect(state.board.cells[cellKey(t.position!)]).toBe(true);
}
void treasuresBefore;
});
it("relocate sector slides everything and keeps adjacency", () => {
@@ -269,6 +217,7 @@ describe("sector manipulation", () => {
// Move my sector to the EAST side of the other sector (still adjacent).
const dest = { x: otherOrigin.x + 5, y: otherOrigin.y };
const posBefore = { ...me.position };
const homeBefore = { ...me.home };
const rel = giveCard(state, me.id, "relocate-sector");
state = must(state, me.id, {
type: "cast", instanceId: rel.instanceId,
@@ -277,7 +226,7 @@ describe("sector manipulation", () => {
const after = state.players.find((p) => p.id === me.id)!;
const dx = dest.x - myOrigin.x, dy = dest.y - myOrigin.y;
expect(after.position).toEqual({ x: posBefore.x + dx, y: posBefore.y + dy });
expect(cellKey(after.home)).toBe(cellKey({ x: after.home.x, y: after.home.y }));
expect(after.home).toEqual({ x: homeBefore.x + dx, y: homeBefore.y + dy });
expect(state.board.placements[idx]!.origin).toEqual(dest);
// The map reassembled: every treasure/wizard cell exists on the new board.
for (const t of state.treasures) {
@@ -1,57 +1,7 @@
import { describe, expect, it } from "vitest";
import {
applyCommand,
activePlayer,
createGame,
displays,
handLimit,
sustainedOn,
type Command,
type GameState,
type PlayerId,
} from "../src/game";
import { applyCommand, activePlayer, displays, handLimit, sustainedOn, 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" });
}
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 {
@@ -1,74 +1,10 @@
import { describe, expect, it } from "vitest";
import {
applyCommand,
activePlayer,
createGame,
boardView,
gameLos,
sustainedOn,
type Command,
type GameState,
type PlayerId,
} from "../src/game";
import { cellKey, edgeKey, neighbor, SIDES, stepTarget, type Cell, type Side } from "../src/board";
import { applyCommand, activePlayer, boardView, gameLos, sustainedOn } from "../src/game";
import { cellKey, edgeKey, neighbor, SIDES } from "../src/board";
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" });
}
import { newGame, must, giveCard, toRound2, faceOff, castAt, emptyNeighborCell } from "./helpers";
/** An empty visible cell adjacent to the player (not home, no treasure). */
function emptyNeighborCell(state: GameState, of: Cell): { cell: Cell; side: Side } {
const view = boardView(state);
for (const side of SIDES) {
const t = stepTarget(view, of, side);
if (t.kind !== "step") continue;
const key = cellKey(t.to);
if (view.homes.some((h) => cellKey(h) === key)) continue;
if (state.treasures.some((tr) => tr.position && cellKey(tr.position) === key)) continue;
if (state.players.some((p) => cellKey(p.position) === key)) continue;
return { cell: t.to, side };
}
throw new Error("no empty neighbor");
}
describe("terrain", () => {
it("fill square with stone blocks movement and line of sight", () => {
let { state } = newGame();
+5 -42
View File
@@ -1,45 +1,8 @@
import { describe, expect, it } from "vitest";
import {
applyCommand,
activePlayer,
createGame,
boardView,
type Command,
type GameState,
type PlayerId,
} from "../src/game";
import { cellKey, edgeKey, SIDES, stepTarget, type Cell, type Side } from "../src/board";
import { applyCommand, activePlayer, boardView } from "../src/game";
import { cellKey, edgeKey, SIDES, type Cell, type Side } from "../src/board";
import type { CardInstance } from "../src/cards";
function newGame(seed = 42) {
return createGame({ playerIds: ["alice", "bob"], seed, sets: ["basic", "expansion1"] });
}
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 };
}
import { newExpansionGame as newGame, must, giveCard, toRound2, faceOff } from "./helpers";
describe("magic wands", () => {
it("blaster wand: charges on first use, once per turn, discards when spent", () => {
@@ -144,7 +107,7 @@ describe("magic wands", () => {
y: d.position.y + (side === "S" ? 1 : side === "N" ? -1 : 0) };
if (view.edges[k] === "wall" && view.cells[cellKey(dest)]) { through = dest; break; }
}
if (!through) return; // no adjacent wall on this seed; covered elsewhere
if (!through) throw new Error("setup: seed 42 lost its adjacent wall");
const wand = giveCard(state, attacker, "shift-wand");
giveCard(state, attacker, "number-2", "N", 1);
state = must(state, attacker, {
@@ -170,7 +133,7 @@ describe("magic wands", () => {
break;
}
}
if (!edge) return;
if (!edge) throw new Error("setup: seed 42 lost its adjacent wall");
const key = edgeKey(edge.cell, edge.side);
const wand = giveCard(state, me.id, "warp-wand");
giveCard(state, me.id, "number-2", "N", 1);