Implement engine core: board assembly, movement, turns, combat, victory

Pure deterministic game core in @wizwar/engine: seeded RNG (mulberry32,
state in GameState so seed+commands replays identically), sector
assembly with rotation, junction merging, and wraparound warps
(configurable pairings; the 2p diagram crosses its side openings),
movement (3 + one number card), geometric line of sight, deck building
from the verified card data (asserts 125/200 totals), and the
command-to-event reducer: setup with TRAP! redraw and die-roll first
player, punching (no combat round 1, no self-attack, once per turn),
damage/death with killer-takes-cards and forced discard, treasure
stealing with both victory conditions, pick-up-ends-turn, and
end-of-turn draw. Events carry full spatial detail for future replay
rendering; private card knowledge rides on visibleTo events with a
redaction helper. 21 tests passing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-15 19:39:50 -04:00
co-authored by Claude Fable 5
parent 11209cdc5d
commit a8884592a4
9 changed files with 1428 additions and 31 deletions
+72
View File
@@ -0,0 +1,72 @@
// Board configurations from the 6e rulebook's setup diagram. Sectors are
// shuffled and randomly rotated, then laid out per player count; each player
// gets the sector containing their home star.
//
// Implemented: 2 players (1x2 column, crossed side pairings per the diagram)
// and 4 players (2x2 square, straight-across pairings). 3 players (L-shape
// with the AUTO WARP corner connector) and 5+ (two sets) are TODO.
import {
assembleBoard,
type AssembledBoard,
type Rotation,
type SectorPlacement,
} from "./board";
import { layoutIds } from "./board";
import { shuffle, nextInt, type RngState } from "./rng";
export const SUPPORTED_PLAYER_COUNTS = [2, 4] as const;
export interface SetupResult {
board: AssembledBoard;
rng: RngState;
}
export function setupBoard(playerCount: number, rng: RngState): SetupResult {
const [shuffled, rng1] = shuffle(rng, layoutIds());
let rngN = rng1;
const pick = (n: number): { id: string; rotation: Rotation }[] => {
const chosen: { id: string; rotation: Rotation }[] = [];
for (let i = 0; i < n; i++) {
const [r, next] = nextInt(rngN, 4);
rngN = next;
chosen.push({ id: shuffled[i]!, rotation: (r * 90) as Rotation });
}
return chosen;
};
if (playerCount === 2) {
const picks = pick(2);
const placements: SectorPlacement[] = [
{ boardId: picks[0]!.id, origin: { x: 0, y: 0 }, rotation: picks[0]!.rotation },
{ boardId: picks[1]!.id, origin: { x: 0, y: 5 }, rotation: picks[1]!.rotation },
];
// Diagram: top {top A, left C, right B}, bottom {left B, right C, bottom A}
// — A wraps vertically; the side openings pair CROSSED (top-left to
// bottom-right and top-right to bottom-left).
const board = assembleBoard(placements, {
warpPairs: [
[{ sector: 0, side: "N" }, { sector: 1, side: "S" }],
[{ sector: 0, side: "W" }, { sector: 1, side: "E" }],
[{ sector: 0, side: "E" }, { sector: 1, side: "W" }],
],
});
return { board, rng: rngN };
}
if (playerCount === 4) {
const picks = pick(4);
const placements: SectorPlacement[] = [
{ boardId: picks[0]!.id, origin: { x: 0, y: 0 }, rotation: picks[0]!.rotation },
{ boardId: picks[1]!.id, origin: { x: 5, y: 0 }, rotation: picks[1]!.rotation },
{ boardId: picks[2]!.id, origin: { x: 0, y: 5 }, rotation: picks[2]!.rotation },
{ boardId: picks[3]!.id, origin: { x: 5, y: 5 }, rotation: picks[3]!.rotation },
];
// 4p square wraps straight across (left openings to right openings on the
// same rows, top to bottom on the same columns) — the default pairing.
const board = assembleBoard(placements);
return { board, rng: rngN };
}
throw new Error(`unsupported player count: ${playerCount} (supported: 2, 4)`);
}