Blind reviews over the rev-37..40 features and the gate collapse. The scripted surgery's scars come out: bare brace blocks unwrapped (with one hiding an unreachable scrambleHands), the amputated comment healed, the dead events parameter unthreaded from the illusion-sight chain, the laziness probe in perceivedBoard collapsed to the rule it always produced, GameView.deckRev retired with its last reader, the ward's arming stub told honestly, and every comment or test title still citing the retired revision numbering reworded to the rule it guards. Rim mechanics unify on rimWarpMouth and breachRim; the test suite gains plainRimWall and pushSustained and loses its loop-shaped scars and rev-named helpers. No behavior changes; 255 tests pass unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
103 lines
4.0 KiB
TypeScript
103 lines
4.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, 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;
|
|
}
|
|
|
|
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 });
|
|
}
|