War-story comments become timeless constraints (carrier rule, boobytrap tell, remove-curse prescription); the rev changelog reunites under GameConfig.deckRev; three hand-copied cardless stack literals fold into openCardlessStack; the visionstone one-edge loop is shared via throughOneEdge with the edge-target hairpin exclusion stated; drain() moves to test helpers and replaces four inline copies; the butt-head sweep test loses its dead scaffolding and its expect(refused || true) tautology and now asserts both branches; a process-named suite is renamed for the behavior it pins; dead .bezel-spacer CSS and the fx effect's fossilized cleanup story go. No behavior changes: 304 engine tests pass, 36/36 ledgers replay, web typecheck clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
111 lines
4.2 KiB
TypeScript
111 lines
4.2 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, edgeKey, neighbor, SIDES, stepTarget, type Cell, type Side } from "../src/board";
|
|
import type { CardInstance } from "../src/cards";
|
|
import type { SustainedEffect } from "../src/game";
|
|
|
|
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;
|
|
}
|
|
|
|
/** Wave every pending counteraction window through unanswered. */
|
|
export function drain(state: GameState): GameState {
|
|
while (state.stack) {
|
|
state = must(state, state.stack.waitingOn, { type: "pass" });
|
|
}
|
|
return 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");
|
|
}
|
|
|
|
/** Some off-board edge that is a bare stone wall — no warp behind it. */
|
|
export function plainRimWall(state: GameState): { cell: Cell; side: Side } {
|
|
const board = boardView(state);
|
|
for (const k of Object.keys(board.cells)) {
|
|
const [x, y] = k.split(",").map(Number) as [number, number];
|
|
for (const side of SIDES) {
|
|
if (board.cells[cellKey(neighbor({ x, y }, side))]) continue;
|
|
if (board.edges[edgeKey({ x, y }, side)] !== "wall") continue;
|
|
if (board.warps.some((w) => cellKey(w.from.cell) === k && w.from.side === side)) continue;
|
|
return { cell: { x, y }, side };
|
|
}
|
|
}
|
|
throw new Error("setup: no plain rim wall on this board");
|
|
}
|
|
|
|
/** Rig a duration spell directly, sparing the cast ceremony. */
|
|
export function pushSustained(
|
|
state: GameState,
|
|
fx: Omit<SustainedEffect, "data"> & { data?: SustainedEffect["data"] },
|
|
): void {
|
|
state.sustained.push({ data: {}, ...fx });
|
|
}
|