Files
wizwar6e/packages/engine/test/helpers.ts
T
Eric WagonerandClaude Fable 5 695307daa8 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>
2026-08-16 11:56:55 -04:00

79 lines
3.0 KiB
TypeScript

// 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");
}