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:
co-authored by
Claude Fable 5
parent
11209cdc5d
commit
a8884592a4
@@ -0,0 +1,308 @@
|
|||||||
|
// Board assembly: sector layouts from data/boards.json are rotated and placed
|
||||||
|
// into one global grid. Cells are {x, y}, 0-indexed, x → east, y → south.
|
||||||
|
// Edges between cells are 'open', 'wall', or 'door'; sector perimeters become
|
||||||
|
// walls except the mid-edge openings, which either join two sectors or become
|
||||||
|
// wraparound warp connections at the outer boundary.
|
||||||
|
|
||||||
|
import boardsData from "../data/boards.json";
|
||||||
|
|
||||||
|
export type Cell = { readonly x: number; readonly y: number };
|
||||||
|
export type Side = "N" | "S" | "E" | "W";
|
||||||
|
export type EdgeState = "open" | "wall" | "door";
|
||||||
|
export type Rotation = 0 | 90 | 180 | 270;
|
||||||
|
|
||||||
|
export interface SectorPlacement {
|
||||||
|
boardId: string;
|
||||||
|
/** Top-left cell of the sector in global coordinates (multiples of 5). */
|
||||||
|
origin: Cell;
|
||||||
|
rotation: Rotation;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Warp {
|
||||||
|
/** Leaving this cell through this side of the map... */
|
||||||
|
from: { cell: Cell; side: Side };
|
||||||
|
/** ...you arrive at this cell (entering through `side`). */
|
||||||
|
to: { cell: Cell; side: Side };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssembledBoard {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
placements: SectorPlacement[];
|
||||||
|
/** Edge states keyed by edgeKey(). Only non-open edges are stored. */
|
||||||
|
edges: Record<string, EdgeState>;
|
||||||
|
/** Cells that are part of the map (all cells inside placed sectors). */
|
||||||
|
cells: Record<string, true>;
|
||||||
|
warps: Warp[];
|
||||||
|
/** Per player-sector info, in placement order. */
|
||||||
|
homes: Cell[];
|
||||||
|
treasureSpaces: Cell[][];
|
||||||
|
}
|
||||||
|
|
||||||
|
const SECTOR = 5;
|
||||||
|
|
||||||
|
export function cellKey(c: Cell): string {
|
||||||
|
return `${c.x},${c.y}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical key for the edge between cell c and its neighbor on `side`.
|
||||||
|
* Every edge is expressed from its north/west cell so both cells agree.
|
||||||
|
*/
|
||||||
|
export function edgeKey(c: Cell, side: Side): string {
|
||||||
|
switch (side) {
|
||||||
|
case "E": return `V:${c.x},${c.y}`;
|
||||||
|
case "W": return `V:${c.x - 1},${c.y}`;
|
||||||
|
case "S": return `H:${c.x},${c.y}`;
|
||||||
|
case "N": return `H:${c.x},${c.y - 1}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function neighbor(c: Cell, side: Side): Cell {
|
||||||
|
switch (side) {
|
||||||
|
case "N": return { x: c.x, y: c.y - 1 };
|
||||||
|
case "S": return { x: c.x, y: c.y + 1 };
|
||||||
|
case "E": return { x: c.x + 1, y: c.y };
|
||||||
|
case "W": return { x: c.x - 1, y: c.y };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SIDES: readonly Side[] = ["N", "S", "E", "W"];
|
||||||
|
|
||||||
|
interface LayoutData {
|
||||||
|
id: string;
|
||||||
|
homeSpace: number[];
|
||||||
|
treasureSpaces: number[][];
|
||||||
|
walls: { cell: number[]; side: string }[];
|
||||||
|
doors: { cell: number[]; side: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const layouts: Map<string, LayoutData> = new Map(
|
||||||
|
(boardsData as { boards: LayoutData[] }).boards.map((b) => [b.id, b]),
|
||||||
|
);
|
||||||
|
|
||||||
|
export function layoutIds(): string[] {
|
||||||
|
return [...layouts.keys()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rotate a sector-local cell (1-indexed [row, col]) within a 5x5 grid. */
|
||||||
|
function rotateCell(row: number, col: number, rotation: Rotation): [number, number] {
|
||||||
|
switch (rotation) {
|
||||||
|
case 0: return [row, col];
|
||||||
|
case 90: return [col, SECTOR + 1 - row]; // clockwise
|
||||||
|
case 180: return [SECTOR + 1 - row, SECTOR + 1 - col];
|
||||||
|
case 270: return [SECTOR + 1 - col, row];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rotateSide(side: Side, rotation: Rotation): Side {
|
||||||
|
const order: Side[] = ["N", "E", "S", "W"];
|
||||||
|
const idx = order.indexOf(side);
|
||||||
|
return order[(idx + rotation / 90) % 4]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert sector-local (1-indexed [row, col]) to a global cell. */
|
||||||
|
function toGlobal(origin: Cell, row: number, col: number): Cell {
|
||||||
|
return { x: origin.x + col - 1, y: origin.y + row - 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An outer-boundary opening, identified by placement index and map side. */
|
||||||
|
export type OpeningRef = { sector: number; side: Side };
|
||||||
|
|
||||||
|
export interface AssemblyOptions {
|
||||||
|
/**
|
||||||
|
* Explicit wraparound pairings (each pair is bidirectional). When omitted,
|
||||||
|
* openings pair straight across the map — correct for the 4p square; the
|
||||||
|
* standard 2p column crosses its side openings and must pass pairs.
|
||||||
|
*/
|
||||||
|
warpPairs?: [OpeningRef, OpeningRef][];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assemble sectors into one board. Interior walls and doors come from the
|
||||||
|
* layout data; sector perimeters become walls except the centered mid-edge
|
||||||
|
* opening on each side. Openings between two adjacent sectors line up (they
|
||||||
|
* are always centered) and stay open; openings on the outer boundary become
|
||||||
|
* wraparound warps ("leave at A, re-enter at A").
|
||||||
|
*/
|
||||||
|
export function assembleBoard(
|
||||||
|
placements: SectorPlacement[],
|
||||||
|
options: AssemblyOptions = {},
|
||||||
|
): AssembledBoard {
|
||||||
|
const edges: Record<string, EdgeState> = {};
|
||||||
|
const cells: Record<string, true> = {};
|
||||||
|
const homes: Cell[] = [];
|
||||||
|
const treasureSpaces: Cell[][] = [];
|
||||||
|
|
||||||
|
let width = 0;
|
||||||
|
let height = 0;
|
||||||
|
|
||||||
|
for (const p of placements) {
|
||||||
|
const layout = layouts.get(p.boardId);
|
||||||
|
if (!layout) throw new Error(`unknown board layout: ${p.boardId}`);
|
||||||
|
if (p.origin.x % SECTOR !== 0 || p.origin.y % SECTOR !== 0) {
|
||||||
|
throw new Error("sector origins must be multiples of 5");
|
||||||
|
}
|
||||||
|
width = Math.max(width, p.origin.x + SECTOR);
|
||||||
|
height = Math.max(height, p.origin.y + SECTOR);
|
||||||
|
|
||||||
|
for (let r = 1; r <= SECTOR; r++) {
|
||||||
|
for (let c = 1; c <= SECTOR; c++) {
|
||||||
|
cells[cellKey(toGlobal(p.origin, r, c))] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [hr, hc] = rotateCell(layout.homeSpace[0]!, layout.homeSpace[1]!, p.rotation);
|
||||||
|
homes.push(toGlobal(p.origin, hr, hc));
|
||||||
|
treasureSpaces.push(
|
||||||
|
layout.treasureSpaces.map((t) => {
|
||||||
|
const [tr, tc] = rotateCell(t[0]!, t[1]!, p.rotation);
|
||||||
|
return toGlobal(p.origin, tr, tc);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const place = (cellRC: number[], side: string, state: EdgeState) => {
|
||||||
|
// Rotating a cell+side pair: rotate the cell, rotate the side.
|
||||||
|
const [r0, c0] = cellRC as [number, number];
|
||||||
|
const [r, c] = rotateCell(r0, c0, p.rotation);
|
||||||
|
const s = rotateSide(side as Side, p.rotation);
|
||||||
|
edges[edgeKey(toGlobal(p.origin, r, c), s)] = state;
|
||||||
|
};
|
||||||
|
for (const w of layout.walls) place(w.cell, w.side, "wall");
|
||||||
|
for (const d of layout.doors) place(d.cell, d.side, "door");
|
||||||
|
|
||||||
|
// Perimeter: wall everywhere except the centered opening (position 3) on
|
||||||
|
// each side. Openings are rotation-invariant because they are centered.
|
||||||
|
for (let i = 1; i <= SECTOR; i++) {
|
||||||
|
if (i !== 3) {
|
||||||
|
edges[edgeKey(toGlobal(p.origin, 1, i), "N")] = "wall";
|
||||||
|
edges[edgeKey(toGlobal(p.origin, SECTOR, i), "S")] = "wall";
|
||||||
|
edges[edgeKey(toGlobal(p.origin, i, 1), "W")] = "wall";
|
||||||
|
edges[edgeKey(toGlobal(p.origin, i, SECTOR), "E")] = "wall";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wraparound warps. An opening that faces another sector is a corridor, not
|
||||||
|
// a warp. The remaining outer-boundary openings pair per options.warpPairs
|
||||||
|
// (from the setup diagram's letters) or, by default, straight across the
|
||||||
|
// map. (L-shaped 3p aisle-warp corners are TODO.)
|
||||||
|
const warps: Warp[] = [];
|
||||||
|
const inMap = (c: Cell) => cells[cellKey(c)] === true;
|
||||||
|
const openingCell = (sectorIndex: number, side: Side): Cell => {
|
||||||
|
const origin = placements[sectorIndex]!.origin;
|
||||||
|
switch (side) {
|
||||||
|
case "N": return toGlobal(origin, 1, 3);
|
||||||
|
case "S": return toGlobal(origin, SECTOR, 3);
|
||||||
|
case "W": return toGlobal(origin, 3, 1);
|
||||||
|
case "E": return toGlobal(origin, 3, SECTOR);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const opposite = (s: Side): Side => (s === "N" ? "S" : s === "S" ? "N" : s === "E" ? "W" : "E");
|
||||||
|
|
||||||
|
if (options.warpPairs) {
|
||||||
|
for (const [a, b] of options.warpPairs) {
|
||||||
|
const ca = openingCell(a.sector, a.side);
|
||||||
|
const cb = openingCell(b.sector, b.side);
|
||||||
|
warps.push({ from: { cell: ca, side: a.side }, to: { cell: cb, side: b.side } });
|
||||||
|
warps.push({ from: { cell: cb, side: b.side }, to: { cell: ca, side: a.side } });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (let i = 0; i < placements.length; i++) {
|
||||||
|
for (const side of SIDES) {
|
||||||
|
const cell = openingCell(i, side);
|
||||||
|
if (inMap(neighbor(cell, side))) continue; // joins an adjacent sector
|
||||||
|
let probe = cell;
|
||||||
|
while (inMap(neighbor(probe, opposite(side)))) probe = neighbor(probe, opposite(side));
|
||||||
|
warps.push({ from: { cell, side }, to: { cell: probe, side: opposite(side) } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { width, height, placements, edges, cells, warps, homes, treasureSpaces };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function edgeState(board: AssembledBoard, c: Cell, side: Side): EdgeState {
|
||||||
|
return board.edges[edgeKey(c, side)] ?? "open";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findWarp(board: AssembledBoard, c: Cell, side: Side): Warp | undefined {
|
||||||
|
return board.warps.find((w) => cellKey(w.from.cell) === cellKey(c) && w.from.side === side);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where does one step from `c` toward `side` lead?
|
||||||
|
* - blocked: a wall or (locked) door is in the way
|
||||||
|
* - normal step to the adjacent cell
|
||||||
|
* - warp step through a map-edge opening
|
||||||
|
*/
|
||||||
|
export function stepTarget(
|
||||||
|
board: AssembledBoard,
|
||||||
|
c: Cell,
|
||||||
|
side: Side,
|
||||||
|
): { kind: "blocked"; by: EdgeState } | { kind: "step" | "warp"; to: Cell } {
|
||||||
|
const e = edgeState(board, c, side);
|
||||||
|
if (e !== "open") return { kind: "blocked", by: e };
|
||||||
|
const n = neighbor(c, side);
|
||||||
|
if (board.cells[cellKey(n)]) return { kind: "step", to: n };
|
||||||
|
const warp = findWarp(board, c, side);
|
||||||
|
if (warp) return { kind: "warp", to: warp.to.cell };
|
||||||
|
return { kind: "blocked", by: "wall" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Line of sight from the center of `from` to the center of `to`, blocked by
|
||||||
|
* wall/door edges the segment crosses. Grazing a wall endpoint (passing
|
||||||
|
* exactly through a corner adjacent to a wall) counts as blocked — strict
|
||||||
|
* reading; revisit against FAQ rulings if needed. LOS through wraparound
|
||||||
|
* openings is not yet modeled (TODO).
|
||||||
|
*/
|
||||||
|
export function hasLineOfSight(board: AssembledBoard, from: Cell, to: Cell): boolean {
|
||||||
|
if (cellKey(from) === cellKey(to)) return true;
|
||||||
|
// Centers of cells: (x + 0.5, y + 0.5).
|
||||||
|
const x0 = from.x + 0.5, y0 = from.y + 0.5;
|
||||||
|
const x1 = to.x + 0.5, y1 = to.y + 0.5;
|
||||||
|
|
||||||
|
for (const [key, state] of Object.entries(board.edges)) {
|
||||||
|
if (state === "open") continue;
|
||||||
|
// Reconstruct the wall segment for this edge.
|
||||||
|
const [kind, coords] = key.split(":") as [string, string];
|
||||||
|
const [ex, ey] = coords.split(",").map(Number) as [number, number];
|
||||||
|
// V:x,y = edge between (x,y) and (x+1,y): vertical segment at x+1 from y to y+1.
|
||||||
|
// H:x,y = edge between (x,y) and (x,y+1): horizontal segment at y+1 from x to x+1.
|
||||||
|
const [ax, ay, bx, by] =
|
||||||
|
kind === "V" ? [ex + 1, ey, ex + 1, ey + 1] : [ex, ey + 1, ex + 1, ey + 1];
|
||||||
|
if (segmentsIntersect(x0, y0, x1, y1, ax, ay, bx, by)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Segment intersection where touching (colinear overlap or endpoint contact) counts. */
|
||||||
|
function segmentsIntersect(
|
||||||
|
p0x: number, p0y: number, p1x: number, p1y: number,
|
||||||
|
p2x: number, p2y: number, p3x: number, p3y: number,
|
||||||
|
): boolean {
|
||||||
|
const d1 = cross(p2x, p2y, p3x, p3y, p0x, p0y);
|
||||||
|
const d2 = cross(p2x, p2y, p3x, p3y, p1x, p1y);
|
||||||
|
const d3 = cross(p0x, p0y, p1x, p1y, p2x, p2y);
|
||||||
|
const d4 = cross(p0x, p0y, p1x, p1y, p3x, p3y);
|
||||||
|
if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (d1 === 0 && onSegment(p2x, p2y, p3x, p3y, p0x, p0y)) return true;
|
||||||
|
if (d2 === 0 && onSegment(p2x, p2y, p3x, p3y, p1x, p1y)) return true;
|
||||||
|
if (d3 === 0 && onSegment(p0x, p0y, p1x, p1y, p2x, p2y)) return true;
|
||||||
|
if (d4 === 0 && onSegment(p0x, p0y, p1x, p1y, p3x, p3y)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cross(ax: number, ay: number, bx: number, by: number, px: number, py: number): number {
|
||||||
|
return (bx - ax) * (py - ay) - (by - ay) * (px - ax);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSegment(ax: number, ay: number, bx: number, by: number, px: number, py: number): boolean {
|
||||||
|
return (
|
||||||
|
Math.min(ax, bx) <= px && px <= Math.max(ax, bx) &&
|
||||||
|
Math.min(ay, by) <= py && py <= Math.max(ay, by)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// Card definitions loaded from data/cards.json (verified against the owner's
|
||||||
|
// physical 6th edition + Expansion Set #1), and physical-deck construction:
|
||||||
|
// each printed copy of a card becomes one CardInstance with a stable id.
|
||||||
|
|
||||||
|
import cardsData from "../data/cards.json";
|
||||||
|
|
||||||
|
export type CardSet = "basic" | "expansion1" | "expansion2";
|
||||||
|
export type CardType =
|
||||||
|
| "attack"
|
||||||
|
| "neutral"
|
||||||
|
| "counteraction"
|
||||||
|
| "neutral/counteraction"
|
||||||
|
| "number"
|
||||||
|
| "object"
|
||||||
|
| "trap"
|
||||||
|
| "artifact"
|
||||||
|
| "special";
|
||||||
|
|
||||||
|
export interface CardDef {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
set: CardSet;
|
||||||
|
cardType: CardType | null;
|
||||||
|
los: boolean | null;
|
||||||
|
text: string | null;
|
||||||
|
quantity: number | null;
|
||||||
|
value?: number; // number cards
|
||||||
|
alsoIn?: { set: CardSet; quantity: number }[];
|
||||||
|
faqRulings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CardInstance {
|
||||||
|
/** e.g. "fireball#2" — stable across the whole game. */
|
||||||
|
instanceId: string;
|
||||||
|
cardId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defs: CardDef[] = (cardsData as { cards: CardDef[] }).cards;
|
||||||
|
const byId = new Map(defs.map((d) => [d.id, d]));
|
||||||
|
|
||||||
|
export function cardDef(cardId: string): CardDef {
|
||||||
|
const def = byId.get(cardId);
|
||||||
|
if (!def) throw new Error(`unknown card: ${cardId}`);
|
||||||
|
return def;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function allCardDefs(): readonly CardDef[] {
|
||||||
|
return defs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the physical deck for the chosen sets. The 6e basic deck is exactly
|
||||||
|
* 125 cards; adding Expansion Set #1 adds exactly 75 more (including its own
|
||||||
|
* number cards) — both counts verified against the owner's rulebook lists.
|
||||||
|
*/
|
||||||
|
export function buildDeck(sets: CardSet[]): CardInstance[] {
|
||||||
|
const instances: CardInstance[] = [];
|
||||||
|
for (const def of defs) {
|
||||||
|
let copies = 0;
|
||||||
|
if (sets.includes(def.set) && def.quantity != null) copies += def.quantity;
|
||||||
|
for (const extra of def.alsoIn ?? []) {
|
||||||
|
if (sets.includes(extra.set)) copies += extra.quantity;
|
||||||
|
}
|
||||||
|
for (let i = 1; i <= copies; i++) {
|
||||||
|
instances.push({ instanceId: `${def.id}#${i}`, cardId: def.id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return instances;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isNumberCard(cardId: string): boolean {
|
||||||
|
return cardDef(cardId).cardType === "number";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function numberValue(cardId: string): number {
|
||||||
|
const def = cardDef(cardId);
|
||||||
|
if (def.cardType !== "number" || def.value == null) {
|
||||||
|
throw new Error(`${cardId} is not a number card`);
|
||||||
|
}
|
||||||
|
return def.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TRAP! is discarded and redrawn if it comes up during the initial deal. */
|
||||||
|
export function isTrap(cardId: string): boolean {
|
||||||
|
return cardId === "trap";
|
||||||
|
}
|
||||||
@@ -0,0 +1,571 @@
|
|||||||
|
// Pure game core: GameState + applyCommand(state, player, command) -> events.
|
||||||
|
// No I/O, no clocks, no Math.random — all randomness flows through the seeded
|
||||||
|
// RNG inside the state, so a (seed, commands) pair replays identically.
|
||||||
|
//
|
||||||
|
// Events deliberately carry full spatial/causal detail (who, from where, to
|
||||||
|
// where, via what) so that replays — including the planned first-person
|
||||||
|
// wizard's-eye renderings — can reconstruct scenes without re-deriving them.
|
||||||
|
//
|
||||||
|
// Implemented in this core: setup/deal, movement (3 + one number card, warps),
|
||||||
|
// punching, damage/death, treasure stealing and both victory conditions, hand
|
||||||
|
// management (draw up to 2 at end of turn, 7-card limit, dead player's cards
|
||||||
|
// to the killer). Spell casting and the counteraction stack are the next
|
||||||
|
// layer and hook in at the marked extension points.
|
||||||
|
|
||||||
|
import {
|
||||||
|
type AssembledBoard,
|
||||||
|
type Cell,
|
||||||
|
type Side,
|
||||||
|
cellKey,
|
||||||
|
stepTarget,
|
||||||
|
} from "./board";
|
||||||
|
import { buildDeck, isNumberCard, isTrap, numberValue, type CardInstance, type CardSet } from "./cards";
|
||||||
|
import { createRng, rollDie, shuffle, type RngState } from "./rng";
|
||||||
|
import { setupBoard } from "./setups";
|
||||||
|
|
||||||
|
export type PlayerId = string;
|
||||||
|
|
||||||
|
export const STARTING_LIFE = 15;
|
||||||
|
export const HAND_LIMIT = 7;
|
||||||
|
export const BASE_MOVEMENT = 3;
|
||||||
|
export const DRAW_PER_TURN = 2;
|
||||||
|
|
||||||
|
export interface TreasureState {
|
||||||
|
id: string;
|
||||||
|
/** The player whose home this treasure belongs to (who "protects" it). */
|
||||||
|
owner: PlayerId;
|
||||||
|
/** Board position, or null while carried. */
|
||||||
|
position: Cell | null;
|
||||||
|
carriedBy: PlayerId | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayerState {
|
||||||
|
id: PlayerId;
|
||||||
|
sectorIndex: number;
|
||||||
|
home: Cell;
|
||||||
|
position: Cell;
|
||||||
|
life: number;
|
||||||
|
/** false once killed OR eliminated by losing both treasures. */
|
||||||
|
alive: boolean;
|
||||||
|
hand: CardInstance[];
|
||||||
|
carriedTreasureId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TurnState {
|
||||||
|
/** 1-based round counter; combat is forbidden during round 1. */
|
||||||
|
round: number;
|
||||||
|
/** Seat of the die-roll winner; rounds advance when play wraps past it. */
|
||||||
|
firstIndex: number;
|
||||||
|
activeIndex: number;
|
||||||
|
movementAllowance: number;
|
||||||
|
movementUsed: number;
|
||||||
|
numberPlayedForMovement: boolean;
|
||||||
|
attackUsed: boolean;
|
||||||
|
/** Picking up any object ends your actions for the turn. */
|
||||||
|
actionsEnded: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameConfig {
|
||||||
|
playerIds: PlayerId[];
|
||||||
|
seed: number;
|
||||||
|
sets: CardSet[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameState {
|
||||||
|
config: GameConfig;
|
||||||
|
phase: "playing" | "finished";
|
||||||
|
board: AssembledBoard;
|
||||||
|
players: PlayerState[];
|
||||||
|
treasures: TreasureState[];
|
||||||
|
deck: CardInstance[];
|
||||||
|
discard: CardInstance[];
|
||||||
|
turn: TurnState;
|
||||||
|
rng: RngState;
|
||||||
|
winner: PlayerId | null;
|
||||||
|
/** Set when a player must discard down to HAND_LIMIT before play continues. */
|
||||||
|
pendingDiscard: PlayerId | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Events
|
||||||
|
|
||||||
|
export type GameEvent =
|
||||||
|
| { type: "gameStarted"; players: PlayerId[]; firstPlayer: PlayerId; dieRolls: Record<PlayerId, number[]>; placements: AssembledBoard["placements"]; homes: Cell[] }
|
||||||
|
| { type: "cardsDealt"; player: PlayerId; count: number }
|
||||||
|
| { type: "cardsDealtPrivate"; visibleTo: PlayerId; player: PlayerId; cards: CardInstance[] }
|
||||||
|
| { type: "trapRedrawnDuringDeal"; player: PlayerId }
|
||||||
|
| { type: "turnStarted"; player: PlayerId; round: number }
|
||||||
|
| { type: "moved"; player: PlayerId; from: Cell; to: Cell; direction: Side; via: "step" | "warp" }
|
||||||
|
| { type: "numberPlayedForMovement"; player: PlayerId; card: CardInstance; value: number; newAllowance: number }
|
||||||
|
| { type: "punched"; attacker: PlayerId; target: PlayerId; at: Cell }
|
||||||
|
| { type: "damaged"; player: PlayerId; amount: number; source: string; lifeAfter: number }
|
||||||
|
| { type: "died"; player: PlayerId; killedBy: PlayerId | null }
|
||||||
|
| { type: "handTaken"; from: PlayerId; to: PlayerId; count: number }
|
||||||
|
| { type: "handTakenPrivate"; visibleTo: PlayerId; cards: CardInstance[] }
|
||||||
|
| { type: "treasurePickedUp"; player: PlayerId; treasureId: string; owner: PlayerId; at: Cell }
|
||||||
|
| { type: "treasureDropped"; player: PlayerId; treasureId: string; at: Cell; onHomeOf: PlayerId | null }
|
||||||
|
| { type: "playerEliminated"; player: PlayerId; reason: "killed" | "treasuresLost" }
|
||||||
|
| { type: "cardsDiscarded"; player: PlayerId; cards: CardInstance[] }
|
||||||
|
| { type: "cardsDrawn"; player: PlayerId; count: number }
|
||||||
|
| { type: "cardsDrawnPrivate"; visibleTo: PlayerId; cards: CardInstance[] }
|
||||||
|
| { type: "deckReshuffled"; size: number }
|
||||||
|
| { type: "turnEnded"; player: PlayerId }
|
||||||
|
| { type: "gameWon"; player: PlayerId; reason: "treasures" | "lastStanding" };
|
||||||
|
|
||||||
|
/** Strip private card knowledge from an event unless `viewer` may see it. */
|
||||||
|
export function redactEvent(event: GameEvent, viewer: PlayerId): GameEvent | null {
|
||||||
|
if ("visibleTo" in event && event.visibleTo !== viewer) return null;
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Commands
|
||||||
|
|
||||||
|
export type Command =
|
||||||
|
| { type: "move"; direction: Side }
|
||||||
|
| { type: "playNumberForMovement"; instanceId: string }
|
||||||
|
| { type: "punch"; targetId: PlayerId }
|
||||||
|
| { type: "pickUpTreasure" }
|
||||||
|
| { type: "dropTreasure" }
|
||||||
|
| { type: "discard"; instanceIds: string[] }
|
||||||
|
| { type: "endTurn"; draw: number };
|
||||||
|
|
||||||
|
export type CommandResult =
|
||||||
|
| { ok: true; state: GameState; events: GameEvent[] }
|
||||||
|
| { ok: false; error: string };
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Setup
|
||||||
|
|
||||||
|
export function createGame(config: GameConfig): { state: GameState; events: GameEvent[] } {
|
||||||
|
const n = config.playerIds.length;
|
||||||
|
let rng = createRng(config.seed);
|
||||||
|
const events: GameEvent[] = [];
|
||||||
|
|
||||||
|
const { board, rng: rng1 } = setupBoard(n, rng);
|
||||||
|
rng = rng1;
|
||||||
|
|
||||||
|
// Each player is randomly assigned one sector ("chooses one of the four
|
||||||
|
// sectors, face down, at random").
|
||||||
|
const [sectorOrder, rng2] = shuffle(rng, board.homes.map((_, i) => i).slice(0, n));
|
||||||
|
rng = rng2;
|
||||||
|
|
||||||
|
const players: PlayerState[] = config.playerIds.map((id, i) => ({
|
||||||
|
id,
|
||||||
|
sectorIndex: sectorOrder[i]!,
|
||||||
|
home: board.homes[sectorOrder[i]!]!,
|
||||||
|
position: board.homes[sectorOrder[i]!]!,
|
||||||
|
life: STARTING_LIFE,
|
||||||
|
alive: true,
|
||||||
|
hand: [],
|
||||||
|
carriedTreasureId: null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const treasures: TreasureState[] = players.flatMap((p, i) =>
|
||||||
|
board.treasureSpaces[p.sectorIndex]!.map((cell, j) => ({
|
||||||
|
id: `treasure-${i}-${j}`,
|
||||||
|
owner: p.id,
|
||||||
|
position: cell,
|
||||||
|
carriedBy: null,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Shuffle and deal 7 each; a TRAP! drawn on the deal is discarded and redrawn.
|
||||||
|
const [deckShuffled, rng3] = shuffle(rng, buildDeck(config.sets));
|
||||||
|
rng = rng3;
|
||||||
|
const deck = [...deckShuffled];
|
||||||
|
const discard: CardInstance[] = [];
|
||||||
|
for (const p of players) {
|
||||||
|
while (p.hand.length < HAND_LIMIT) {
|
||||||
|
const card = deck.shift();
|
||||||
|
if (!card) throw new Error("deck exhausted during deal");
|
||||||
|
if (isTrap(card.cardId)) {
|
||||||
|
discard.push(card);
|
||||||
|
events.push({ type: "trapRedrawnDuringDeal", player: p.id });
|
||||||
|
} else {
|
||||||
|
p.hand.push(card);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
events.push({ type: "cardsDealt", player: p.id, count: p.hand.length });
|
||||||
|
events.push({ type: "cardsDealtPrivate", visibleTo: p.id, player: p.id, cards: [...p.hand] });
|
||||||
|
}
|
||||||
|
|
||||||
|
// First player: highest die roll, rerolling ties among the leaders.
|
||||||
|
const dieRolls: Record<PlayerId, number[]> = Object.fromEntries(players.map((p) => [p.id, []]));
|
||||||
|
let contenders = players.map((_, i) => i);
|
||||||
|
let firstIndex = contenders[0]!;
|
||||||
|
while (contenders.length > 1) {
|
||||||
|
const rolls = new Map<number, number>();
|
||||||
|
for (const i of contenders) {
|
||||||
|
const [roll, next] = rollDie(rng);
|
||||||
|
rng = next;
|
||||||
|
rolls.set(i, roll);
|
||||||
|
dieRolls[players[i]!.id]!.push(roll);
|
||||||
|
}
|
||||||
|
const high = Math.max(...rolls.values());
|
||||||
|
contenders = contenders.filter((i) => rolls.get(i) === high);
|
||||||
|
firstIndex = contenders[0]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state: GameState = {
|
||||||
|
config,
|
||||||
|
phase: "playing",
|
||||||
|
board,
|
||||||
|
players,
|
||||||
|
treasures,
|
||||||
|
deck,
|
||||||
|
discard,
|
||||||
|
turn: {
|
||||||
|
round: 1,
|
||||||
|
firstIndex,
|
||||||
|
activeIndex: firstIndex,
|
||||||
|
movementAllowance: BASE_MOVEMENT,
|
||||||
|
movementUsed: 0,
|
||||||
|
numberPlayedForMovement: false,
|
||||||
|
attackUsed: false,
|
||||||
|
actionsEnded: false,
|
||||||
|
},
|
||||||
|
rng,
|
||||||
|
winner: null,
|
||||||
|
pendingDiscard: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
events.unshift({
|
||||||
|
type: "gameStarted",
|
||||||
|
players: config.playerIds,
|
||||||
|
firstPlayer: players[firstIndex]!.id,
|
||||||
|
dieRolls,
|
||||||
|
placements: board.placements,
|
||||||
|
homes: board.homes,
|
||||||
|
});
|
||||||
|
events.push({ type: "turnStarted", player: players[firstIndex]!.id, round: 1 });
|
||||||
|
return { state, events };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Command application
|
||||||
|
|
||||||
|
export function applyCommand(state: GameState, playerId: PlayerId, command: Command): CommandResult {
|
||||||
|
if (state.phase !== "playing") return err("game is over");
|
||||||
|
|
||||||
|
if (state.pendingDiscard) {
|
||||||
|
if (playerId !== state.pendingDiscard) return err("waiting for another player to discard");
|
||||||
|
if (command.type !== "discard") return err("you must discard down to the hand limit first");
|
||||||
|
} else if (activePlayer(state).id !== playerId) {
|
||||||
|
// Out-of-turn play is only for counteractions (future casting layer).
|
||||||
|
return err("not your turn");
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (command.type) {
|
||||||
|
case "move": return doMove(state, command.direction);
|
||||||
|
case "playNumberForMovement": return doPlayNumberForMovement(state, command.instanceId);
|
||||||
|
case "punch": return doPunch(state, command.targetId);
|
||||||
|
case "pickUpTreasure": return doPickUpTreasure(state);
|
||||||
|
case "dropTreasure": return doDropTreasure(state);
|
||||||
|
case "discard": return doDiscard(state, playerId, command.instanceIds);
|
||||||
|
case "endTurn": return doEndTurn(state, command.draw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function err(error: string): CommandResult {
|
||||||
|
return { ok: false, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function activePlayer(state: GameState): PlayerState {
|
||||||
|
return state.players[state.turn.activeIndex]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clone(state: GameState): GameState {
|
||||||
|
return structuredClone(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireActionsAvailable(state: GameState): string | null {
|
||||||
|
if (state.turn.actionsEnded) return "your turn's actions ended when you picked up an object";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function doMove(prev: GameState, direction: Side): CommandResult {
|
||||||
|
const blocked = requireActionsAvailable(prev);
|
||||||
|
if (blocked) return err(blocked);
|
||||||
|
if (prev.turn.movementUsed >= prev.turn.movementAllowance) return err("no movement left");
|
||||||
|
|
||||||
|
const state = clone(prev);
|
||||||
|
const p = activePlayer(state);
|
||||||
|
const target = stepTarget(state.board, p.position, direction);
|
||||||
|
if (target.kind === "blocked") return err(`blocked by ${target.by}`);
|
||||||
|
|
||||||
|
const from = p.position;
|
||||||
|
p.position = target.to;
|
||||||
|
state.turn.movementUsed++;
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
state,
|
||||||
|
events: [{ type: "moved", player: p.id, from, to: p.position, direction, via: target.kind }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function doPlayNumberForMovement(prev: GameState, instanceId: string): CommandResult {
|
||||||
|
const blocked = requireActionsAvailable(prev);
|
||||||
|
if (blocked) return err(blocked);
|
||||||
|
if (prev.turn.numberPlayedForMovement) return err("only one number card may boost movement per turn");
|
||||||
|
|
||||||
|
const state = clone(prev);
|
||||||
|
const p = activePlayer(state);
|
||||||
|
const idx = p.hand.findIndex((c) => c.instanceId === instanceId);
|
||||||
|
if (idx === -1) return err("card not in hand");
|
||||||
|
const card = p.hand[idx]!;
|
||||||
|
if (!isNumberCard(card.cardId)) return err("not a number card");
|
||||||
|
|
||||||
|
const value = numberValue(card.cardId);
|
||||||
|
p.hand.splice(idx, 1);
|
||||||
|
state.discard.push(card);
|
||||||
|
state.turn.movementAllowance += value;
|
||||||
|
state.turn.numberPlayedForMovement = true;
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
state,
|
||||||
|
events: [{
|
||||||
|
type: "numberPlayedForMovement",
|
||||||
|
player: p.id,
|
||||||
|
card,
|
||||||
|
value,
|
||||||
|
newAllowance: state.turn.movementAllowance,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function doPunch(prev: GameState, targetId: PlayerId): CommandResult {
|
||||||
|
const blocked = requireActionsAvailable(prev);
|
||||||
|
if (blocked) return err(blocked);
|
||||||
|
if (prev.turn.round === 1) return err("no combat during the first round of turns");
|
||||||
|
if (prev.turn.attackUsed) return err("you may attack only once per turn");
|
||||||
|
|
||||||
|
const state = clone(prev);
|
||||||
|
const attacker = activePlayer(state);
|
||||||
|
if (targetId === attacker.id) return err("you cannot attack yourself");
|
||||||
|
const target = state.players.find((p) => p.id === targetId);
|
||||||
|
if (!target || !target.alive) return err("no such living player");
|
||||||
|
if (cellKey(target.position) !== cellKey(attacker.position)) {
|
||||||
|
return err("you must be in the same square to punch");
|
||||||
|
}
|
||||||
|
|
||||||
|
state.turn.attackUsed = true;
|
||||||
|
const events: GameEvent[] = [
|
||||||
|
{ type: "punched", attacker: attacker.id, target: target.id, at: attacker.position },
|
||||||
|
];
|
||||||
|
applyDamage(state, events, target, 1, `punch from ${attacker.id}`, attacker.id);
|
||||||
|
checkVictory(state, events);
|
||||||
|
return { ok: true, state, events };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Damage, death, killer-takes-cards, elimination — shared with future spells. */
|
||||||
|
function applyDamage(
|
||||||
|
state: GameState,
|
||||||
|
events: GameEvent[],
|
||||||
|
target: PlayerState,
|
||||||
|
amount: number,
|
||||||
|
source: string,
|
||||||
|
attackerId: PlayerId | null,
|
||||||
|
): void {
|
||||||
|
target.life -= amount;
|
||||||
|
events.push({ type: "damaged", player: target.id, amount, source, lifeAfter: target.life });
|
||||||
|
if (target.life > 0) return;
|
||||||
|
|
||||||
|
target.alive = false;
|
||||||
|
events.push({ type: "died", player: target.id, killedBy: attackerId });
|
||||||
|
events.push({ type: "playerEliminated", player: target.id, reason: "killed" });
|
||||||
|
|
||||||
|
// A carried treasure drops where they fell.
|
||||||
|
if (target.carriedTreasureId) {
|
||||||
|
const t = state.treasures.find((t) => t.id === target.carriedTreasureId)!;
|
||||||
|
t.carriedBy = null;
|
||||||
|
t.position = target.position;
|
||||||
|
target.carriedTreasureId = null;
|
||||||
|
events.push({
|
||||||
|
type: "treasureDropped",
|
||||||
|
player: target.id,
|
||||||
|
treasureId: t.id,
|
||||||
|
at: target.position,
|
||||||
|
onHomeOf: homeOwnerAt(state, target.position),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// "If you kill an opponent, you get all his cards, but you must immediately
|
||||||
|
// discard enough to bring your hand down to seven cards."
|
||||||
|
const killer = attackerId ? state.players.find((p) => p.id === attackerId) : undefined;
|
||||||
|
if (killer && killer.alive && target.hand.length > 0) {
|
||||||
|
const taken = target.hand.splice(0);
|
||||||
|
killer.hand.push(...taken);
|
||||||
|
events.push({ type: "handTaken", from: target.id, to: killer.id, count: taken.length });
|
||||||
|
events.push({ type: "handTakenPrivate", visibleTo: killer.id, cards: taken });
|
||||||
|
if (killer.hand.length > HAND_LIMIT) state.pendingDiscard = killer.id;
|
||||||
|
} else if (target.hand.length > 0) {
|
||||||
|
state.discard.push(...target.hand.splice(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function homeOwnerAt(state: GameState, cell: Cell): PlayerId | null {
|
||||||
|
const p = state.players.find((p) => cellKey(p.home) === cellKey(cell));
|
||||||
|
return p ? p.id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function doPickUpTreasure(prev: GameState): CommandResult {
|
||||||
|
const blocked = requireActionsAvailable(prev);
|
||||||
|
if (blocked) return err(blocked);
|
||||||
|
|
||||||
|
const state = clone(prev);
|
||||||
|
const p = activePlayer(state);
|
||||||
|
if (p.carriedTreasureId) return err("you can only carry one treasure at a time");
|
||||||
|
const t = state.treasures.find(
|
||||||
|
(t) => t.position && cellKey(t.position) === cellKey(p.position) && !t.carriedBy,
|
||||||
|
);
|
||||||
|
if (!t) return err("no treasure here");
|
||||||
|
|
||||||
|
t.carriedBy = p.id;
|
||||||
|
t.position = null;
|
||||||
|
p.carriedTreasureId = t.id;
|
||||||
|
// Picking up any object ends the turn's actions (drawing is still allowed).
|
||||||
|
state.turn.actionsEnded = true;
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
state,
|
||||||
|
events: [{ type: "treasurePickedUp", player: p.id, treasureId: t.id, owner: t.owner, at: p.position }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function doDropTreasure(prev: GameState): CommandResult {
|
||||||
|
const state = clone(prev);
|
||||||
|
const p = activePlayer(state);
|
||||||
|
if (!p.carriedTreasureId) return err("you are not carrying a treasure");
|
||||||
|
const t = state.treasures.find((t) => t.id === p.carriedTreasureId)!;
|
||||||
|
|
||||||
|
t.carriedBy = null;
|
||||||
|
t.position = p.position;
|
||||||
|
p.carriedTreasureId = null;
|
||||||
|
const events: GameEvent[] = [{
|
||||||
|
type: "treasureDropped",
|
||||||
|
player: p.id,
|
||||||
|
treasureId: t.id,
|
||||||
|
at: p.position,
|
||||||
|
onHomeOf: homeOwnerAt(state, p.position),
|
||||||
|
}];
|
||||||
|
checkVictory(state, events);
|
||||||
|
return { ok: true, state, events };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Both win conditions plus treasure-loss elimination. */
|
||||||
|
function checkVictory(state: GameState, events: GameEvent[]): void {
|
||||||
|
if (state.phase !== "playing") return;
|
||||||
|
|
||||||
|
// Elimination: both of a player's treasures sit on OTHER living players' homes.
|
||||||
|
for (const p of state.players) {
|
||||||
|
if (!p.alive) continue;
|
||||||
|
const mine = state.treasures.filter((t) => t.owner === p.id);
|
||||||
|
const lost = mine.every((t) => {
|
||||||
|
if (!t.position) return false;
|
||||||
|
const owner = homeOwnerAt(state, t.position);
|
||||||
|
const ownerPlayer = owner ? state.players.find((q) => q.id === owner) : null;
|
||||||
|
return owner !== null && owner !== p.id && ownerPlayer?.alive === true;
|
||||||
|
});
|
||||||
|
if (lost) {
|
||||||
|
p.alive = false;
|
||||||
|
state.discard.push(...p.hand.splice(0));
|
||||||
|
events.push({ type: "playerEliminated", player: p.id, reason: "treasuresLost" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Win by treasures: two enemy treasures resting on your home base.
|
||||||
|
for (const p of state.players) {
|
||||||
|
if (!p.alive) continue;
|
||||||
|
const stolenAtHome = state.treasures.filter(
|
||||||
|
(t) => t.owner !== p.id && t.position && cellKey(t.position) === cellKey(p.home),
|
||||||
|
);
|
||||||
|
if (stolenAtHome.length >= 2) {
|
||||||
|
state.phase = "finished";
|
||||||
|
state.winner = p.id;
|
||||||
|
events.push({ type: "gameWon", player: p.id, reason: "treasures" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Win by elimination: last wizard standing.
|
||||||
|
const alive = state.players.filter((p) => p.alive);
|
||||||
|
if (alive.length === 1) {
|
||||||
|
state.phase = "finished";
|
||||||
|
state.winner = alive[0]!.id;
|
||||||
|
events.push({ type: "gameWon", player: alive[0]!.id, reason: "lastStanding" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function doDiscard(prev: GameState, playerId: PlayerId, instanceIds: string[]): CommandResult {
|
||||||
|
const state = clone(prev);
|
||||||
|
const p = state.players.find((p) => p.id === playerId)!;
|
||||||
|
const cards: CardInstance[] = [];
|
||||||
|
for (const id of instanceIds) {
|
||||||
|
const idx = p.hand.findIndex((c) => c.instanceId === id);
|
||||||
|
if (idx === -1) return err(`card not in hand: ${id}`);
|
||||||
|
cards.push(...p.hand.splice(idx, 1));
|
||||||
|
}
|
||||||
|
state.discard.push(...cards);
|
||||||
|
if (state.pendingDiscard === playerId && p.hand.length <= HAND_LIMIT) {
|
||||||
|
state.pendingDiscard = null;
|
||||||
|
}
|
||||||
|
return { ok: true, state, events: [{ type: "cardsDiscarded", player: p.id, cards }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function doEndTurn(prev: GameState, draw: number): CommandResult {
|
||||||
|
if (draw < 0 || draw > DRAW_PER_TURN) return err(`you may draw 0-${DRAW_PER_TURN} cards`);
|
||||||
|
if (prev.pendingDiscard) return err("a discard is pending");
|
||||||
|
|
||||||
|
const state = clone(prev);
|
||||||
|
const p = activePlayer(state);
|
||||||
|
const events: GameEvent[] = [];
|
||||||
|
|
||||||
|
// "Draw new cards only at the end of YOUR turn ... may never have more than
|
||||||
|
// seven cards in your hand."
|
||||||
|
const room = HAND_LIMIT - p.hand.length;
|
||||||
|
const count = Math.min(draw, room);
|
||||||
|
if (count > 0) {
|
||||||
|
const drawn: CardInstance[] = [];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
if (state.deck.length === 0) {
|
||||||
|
// Reshuffle the discard pile into a fresh deck. (TODO: confirm the
|
||||||
|
// official rule for deck exhaustion — not covered in the 6e rulebook.)
|
||||||
|
const [reshuffled, rngNext] = shuffle(state.rng, state.discard);
|
||||||
|
state.rng = rngNext;
|
||||||
|
state.deck = reshuffled;
|
||||||
|
state.discard = [];
|
||||||
|
events.push({ type: "deckReshuffled", size: state.deck.length });
|
||||||
|
if (state.deck.length === 0) break;
|
||||||
|
}
|
||||||
|
drawn.push(state.deck.shift()!);
|
||||||
|
}
|
||||||
|
p.hand.push(...drawn);
|
||||||
|
events.push({ type: "cardsDrawn", player: p.id, count: drawn.length });
|
||||||
|
events.push({ type: "cardsDrawnPrivate", visibleTo: p.id, cards: drawn });
|
||||||
|
}
|
||||||
|
|
||||||
|
events.push({ type: "turnEnded", player: p.id });
|
||||||
|
|
||||||
|
// Advance to the next living player; a full cycle back past the first
|
||||||
|
// player of the round increments the round counter.
|
||||||
|
const n = state.players.length;
|
||||||
|
let next = state.turn.activeIndex;
|
||||||
|
do {
|
||||||
|
next = (next + 1) % n;
|
||||||
|
if (next === state.turn.firstIndex) state.turn.round++;
|
||||||
|
} while (!state.players[next]!.alive);
|
||||||
|
|
||||||
|
state.turn = {
|
||||||
|
round: state.turn.round,
|
||||||
|
firstIndex: state.turn.firstIndex,
|
||||||
|
activeIndex: next,
|
||||||
|
movementAllowance: BASE_MOVEMENT,
|
||||||
|
movementUsed: 0,
|
||||||
|
numberPlayedForMovement: false,
|
||||||
|
attackUsed: false,
|
||||||
|
actionsEnded: false,
|
||||||
|
};
|
||||||
|
events.push({ type: "turnStarted", player: state.players[next]!.id, round: state.turn.round });
|
||||||
|
return { ok: true, state, events };
|
||||||
|
}
|
||||||
@@ -1,32 +1,10 @@
|
|||||||
// @wizwar/engine — pure, deterministic game logic. No I/O, no timers, no
|
// @wizwar/engine — pure, deterministic Wiz-War (6th edition) game logic.
|
||||||
// network: the server drives it in real time, the async mode replays it from
|
// No I/O, no timers, no network: the server drives it in real time, async
|
||||||
// stored commands, and future AI players call it to evaluate moves.
|
// mode replays it from stored commands, and AI players call it to evaluate
|
||||||
//
|
// moves. All game data is verified against the owner's physical 6e set.
|
||||||
// Card data, board layouts, and precise rule mechanics are pending
|
|
||||||
// transcription of the 6th edition materials (see /research).
|
|
||||||
|
|
||||||
export interface PlayerId {
|
export * from "./rng";
|
||||||
readonly id: string;
|
export * from "./board";
|
||||||
}
|
export * from "./cards";
|
||||||
|
export * from "./setups";
|
||||||
/** A single square on a sector board. Walls live on edges between squares. */
|
export * from "./game";
|
||||||
export interface Position {
|
|
||||||
readonly x: number;
|
|
||||||
readonly y: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type GamePhase = "setup" | "playing" | "finished";
|
|
||||||
|
|
||||||
export interface GameState {
|
|
||||||
readonly phase: GamePhase;
|
|
||||||
readonly turn: number;
|
|
||||||
readonly activePlayer: string;
|
|
||||||
// TODO: board (sectors, walls, doors), players (position, life, hand,
|
|
||||||
// sustained spells, carried items/treasures), deck & discard, RNG seed.
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Commands are player intents; the engine validates and applies them. */
|
|
||||||
export type Command = { type: "todo" };
|
|
||||||
|
|
||||||
/** Events are what actually happened; clients render from these. */
|
|
||||||
export type GameEvent = { type: "todo" };
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// Deterministic RNG (mulberry32). The generator state lives inside GameState so
|
||||||
|
// that replaying the same commands from the same seed reproduces the game
|
||||||
|
// exactly — the foundation for async play, spectating, and replays.
|
||||||
|
|
||||||
|
export interface RngState {
|
||||||
|
/** Current 32-bit mulberry32 state; advances by one per value drawn. */
|
||||||
|
readonly a: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRng(seed: number): RngState {
|
||||||
|
return { a: seed >>> 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Draw the next float in [0, 1). Returns the value and the advanced state. */
|
||||||
|
export function nextFloat(state: RngState): [number, RngState] {
|
||||||
|
const a = (state.a + 0x6d2b79f5) | 0;
|
||||||
|
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||||
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||||
|
const value = ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
return [value, { a: a >>> 0 }];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Draw an integer in [0, n). */
|
||||||
|
export function nextInt(state: RngState, n: number): [number, RngState] {
|
||||||
|
const [f, next] = nextFloat(state);
|
||||||
|
return [Math.floor(f * n), next];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Roll the Wiz-War die (numbered 1-4). */
|
||||||
|
export function rollDie(state: RngState): [number, RngState] {
|
||||||
|
const [i, next] = nextInt(state, 4);
|
||||||
|
return [i + 1, next];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fisher-Yates shuffle. Returns a new array and the advanced state. */
|
||||||
|
export function shuffle<T>(state: RngState, items: readonly T[]): [T[], RngState] {
|
||||||
|
const arr = [...items];
|
||||||
|
let rng = state;
|
||||||
|
for (let i = arr.length - 1; i > 0; i--) {
|
||||||
|
const [j, next] = nextInt(rng, i + 1);
|
||||||
|
rng = next;
|
||||||
|
[arr[i], arr[j]] = [arr[j]!, arr[i]!];
|
||||||
|
}
|
||||||
|
return [arr, rng];
|
||||||
|
}
|
||||||
@@ -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)`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
assembleBoard,
|
||||||
|
edgeState,
|
||||||
|
hasLineOfSight,
|
||||||
|
layoutIds,
|
||||||
|
stepTarget,
|
||||||
|
type SectorPlacement,
|
||||||
|
} from "../src/board";
|
||||||
|
import { buildDeck } from "../src/cards";
|
||||||
|
|
||||||
|
describe("card data", () => {
|
||||||
|
it("basic deck is exactly 125 cards (official 6e rulebook)", () => {
|
||||||
|
expect(buildDeck(["basic"]).length).toBe(125);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("basic + expansion1 is exactly 200 cards (125 + 75)", () => {
|
||||||
|
expect(buildDeck(["basic", "expansion1"]).length).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("number cards follow the official distribution", () => {
|
||||||
|
const deck = buildDeck(["basic"]);
|
||||||
|
const count = (id: string) => deck.filter((c) => c.cardId === id).length;
|
||||||
|
expect(count("number-2")).toBe(12);
|
||||||
|
expect(count("number-3")).toBe(10);
|
||||||
|
expect(count("number-4")).toBe(7);
|
||||||
|
expect(count("number-5")).toBe(4);
|
||||||
|
expect(count("number-6")).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("board assembly", () => {
|
||||||
|
it("knows all six verified layouts", () => {
|
||||||
|
expect(layoutIds().sort()).toEqual(
|
||||||
|
["board-a", "board-b", "board-c", "board-d", "board-e", "board-f"].sort(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const twoSector: SectorPlacement[] = [
|
||||||
|
{ boardId: "board-a", origin: { x: 0, y: 0 }, rotation: 0 },
|
||||||
|
{ boardId: "board-b", origin: { x: 0, y: 5 }, rotation: 0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
it("merges the junction between stacked sectors into a single wall with a centered corridor", () => {
|
||||||
|
const board = assembleBoard(twoSector);
|
||||||
|
// Junction edges y=4/5: wall everywhere except the centered opening x=2.
|
||||||
|
expect(edgeState(board, { x: 0, y: 4 }, "S")).toBe("wall");
|
||||||
|
expect(edgeState(board, { x: 1, y: 4 }, "S")).toBe("wall");
|
||||||
|
expect(edgeState(board, { x: 2, y: 4 }, "S")).toBe("open");
|
||||||
|
expect(edgeState(board, { x: 3, y: 4 }, "S")).toBe("wall");
|
||||||
|
expect(edgeState(board, { x: 4, y: 4 }, "S")).toBe("wall");
|
||||||
|
const step = stepTarget(board, { x: 2, y: 4 }, "S");
|
||||||
|
expect(step).toEqual({ kind: "step", to: { x: 2, y: 5 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wraps the vertical openings top-to-bottom by default", () => {
|
||||||
|
const board = assembleBoard(twoSector);
|
||||||
|
const up = stepTarget(board, { x: 2, y: 0 }, "N");
|
||||||
|
expect(up).toEqual({ kind: "warp", to: { x: 2, y: 9 } });
|
||||||
|
const down = stepTarget(board, { x: 2, y: 9 }, "S");
|
||||||
|
expect(down).toEqual({ kind: "warp", to: { x: 2, y: 0 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honors explicit crossed pairings (2-player diagram)", () => {
|
||||||
|
const board = assembleBoard(twoSector, {
|
||||||
|
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" }],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
// Leaving the top sector's west opening arrives at the bottom sector's east.
|
||||||
|
expect(stepTarget(board, { x: 0, y: 2 }, "W")).toEqual({ kind: "warp", to: { x: 4, y: 7 } });
|
||||||
|
expect(stepTarget(board, { x: 4, y: 7 }, "E")).toEqual({ kind: "warp", to: { x: 0, y: 2 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks steps through walls and doors", () => {
|
||||||
|
const board = assembleBoard([{ boardId: "board-a", origin: { x: 0, y: 0 }, rotation: 0 }]);
|
||||||
|
// Layout A wall V (2,1)|(2,2): 1-indexed row 2 col 1 east = 0-indexed (0,1) E.
|
||||||
|
expect(stepTarget(board, { x: 0, y: 1 }, "E")).toEqual({ kind: "blocked", by: "wall" });
|
||||||
|
// Layout A door H (3,2)-(4,2): 0-indexed (1,2) S.
|
||||||
|
expect(stepTarget(board, { x: 1, y: 2 }, "S")).toEqual({ kind: "blocked", by: "door" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rotating a sector 180 degrees preserves its wall count", () => {
|
||||||
|
const flat = assembleBoard([{ boardId: "board-c", origin: { x: 0, y: 0 }, rotation: 0 }]);
|
||||||
|
const rotated = assembleBoard([{ boardId: "board-c", origin: { x: 0, y: 0 }, rotation: 180 }]);
|
||||||
|
const countWalls = (b: typeof flat) =>
|
||||||
|
Object.values(b.edges).filter((e) => e === "wall").length;
|
||||||
|
const countDoors = (b: typeof flat) =>
|
||||||
|
Object.values(b.edges).filter((e) => e === "door").length;
|
||||||
|
expect(countWalls(rotated)).toBe(countWalls(flat));
|
||||||
|
expect(countDoors(rotated)).toBe(countDoors(flat));
|
||||||
|
// Home stays centered under rotation.
|
||||||
|
expect(rotated.homes[0]).toEqual({ x: 2, y: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("computes line of sight blocked by walls", () => {
|
||||||
|
const board = assembleBoard([{ boardId: "board-a", origin: { x: 0, y: 0 }, rotation: 0 }]);
|
||||||
|
// Straight down col 0 from (0,0): wall H (3,1)|(4,1) = 0-indexed (0,2) S blocks.
|
||||||
|
expect(hasLineOfSight(board, { x: 0, y: 0 }, { x: 0, y: 1 })).toBe(true);
|
||||||
|
expect(hasLineOfSight(board, { x: 0, y: 0 }, { x: 0, y: 4 })).toBe(false);
|
||||||
|
// Home (2,2) sees one cell up (open edge), but the wall between rows 1-2
|
||||||
|
// of the home column blocks sight to the top row.
|
||||||
|
expect(hasLineOfSight(board, { x: 2, y: 2 }, { x: 2, y: 1 })).toBe(true);
|
||||||
|
expect(hasLineOfSight(board, { x: 2, y: 2 }, { x: 2, y: 0 })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
applyCommand,
|
||||||
|
activePlayer,
|
||||||
|
createGame,
|
||||||
|
redactEvent,
|
||||||
|
BASE_MOVEMENT,
|
||||||
|
HAND_LIMIT,
|
||||||
|
STARTING_LIFE,
|
||||||
|
type Command,
|
||||||
|
type GameState,
|
||||||
|
type PlayerId,
|
||||||
|
} from "../src/game";
|
||||||
|
import { cellKey, stepTarget, SIDES, type Side } from "../src/board";
|
||||||
|
import { isNumberCard } from "../src/cards";
|
||||||
|
|
||||||
|
function newGame(seed = 42, players = ["alice", "bob"]) {
|
||||||
|
return createGame({ playerIds: players, 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find a direction the active player can actually step. */
|
||||||
|
function openDirection(state: GameState): Side {
|
||||||
|
const p = activePlayer(state);
|
||||||
|
for (const side of SIDES) {
|
||||||
|
if (stepTarget(state.board, p.position, side).kind !== "blocked") return side;
|
||||||
|
}
|
||||||
|
throw new Error("no open direction");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("game setup", () => {
|
||||||
|
it("is deterministic for a given seed", () => {
|
||||||
|
const a = newGame(7);
|
||||||
|
const b = newGame(7);
|
||||||
|
expect(JSON.stringify(a.state)).toEqual(JSON.stringify(b.state));
|
||||||
|
expect(a.events).toEqual(b.events);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deals 7 non-TRAP cards to each player, 15 life, wizard on home star", () => {
|
||||||
|
const { state } = newGame();
|
||||||
|
for (const p of state.players) {
|
||||||
|
expect(p.hand.length).toBe(HAND_LIMIT);
|
||||||
|
expect(p.hand.some((c) => c.cardId === "trap")).toBe(false);
|
||||||
|
expect(p.life).toBe(STARTING_LIFE);
|
||||||
|
expect(cellKey(p.position)).toBe(cellKey(p.home));
|
||||||
|
}
|
||||||
|
// Two treasures per player, on the sector's treasure spaces.
|
||||||
|
expect(state.treasures.length).toBe(4);
|
||||||
|
// Full deck conservation: deck + discard + hands = 125.
|
||||||
|
const total =
|
||||||
|
state.deck.length + state.discard.length +
|
||||||
|
state.players.reduce((sum, p) => sum + p.hand.length, 0);
|
||||||
|
expect(total).toBe(125);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redacts private deal events for other players", () => {
|
||||||
|
const { events } = newGame();
|
||||||
|
const alicePrivate = events.find(
|
||||||
|
(e) => e.type === "cardsDealtPrivate" && e.visibleTo === "alice",
|
||||||
|
)!;
|
||||||
|
expect(redactEvent(alicePrivate, "bob")).toBeNull();
|
||||||
|
expect(redactEvent(alicePrivate, "alice")).toBe(alicePrivate);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("movement", () => {
|
||||||
|
it("allows 3 steps, then stops until a number card is played", () => {
|
||||||
|
let { state } = newGame();
|
||||||
|
const me = activePlayer(state).id;
|
||||||
|
for (let i = 0; i < BASE_MOVEMENT; i++) {
|
||||||
|
state = must(state, me, { type: "move", direction: openDirection(state) });
|
||||||
|
}
|
||||||
|
const refused = applyCommand(state, me, { type: "move", direction: openDirection(state) });
|
||||||
|
expect(refused.ok).toBe(false);
|
||||||
|
|
||||||
|
const numberCard = activePlayer(state).hand.find((c) => isNumberCard(c.cardId));
|
||||||
|
if (numberCard) {
|
||||||
|
state = must(state, me, { type: "playNumberForMovement", instanceId: numberCard.instanceId });
|
||||||
|
const again = applyCommand(state, me, { type: "move", direction: openDirection(state) });
|
||||||
|
expect(again.ok).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects out-of-turn commands", () => {
|
||||||
|
const { state } = newGame();
|
||||||
|
const notMe = state.players.find((p) => p.id !== activePlayer(state).id)!.id;
|
||||||
|
const result = applyCommand(state, notMe, { type: "move", direction: "N" });
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("turns and combat", () => {
|
||||||
|
it("forbids combat in round 1, allows punching in round 2 in the same square", () => {
|
||||||
|
let { state } = newGame();
|
||||||
|
// Round 1: even a hypothetical punch is refused before target checks.
|
||||||
|
const r1 = applyCommand(state, activePlayer(state).id, {
|
||||||
|
type: "punch",
|
||||||
|
targetId: state.players.find((p) => p.id !== activePlayer(state).id)!.id,
|
||||||
|
});
|
||||||
|
expect(r1.ok).toBe(false);
|
||||||
|
expect((r1 as { error: string }).error).toMatch(/first round/);
|
||||||
|
|
||||||
|
// March both players' turns forward into round 2.
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Teleport the opponent into the active player's square (test surgery),
|
||||||
|
// then punch: 1 damage, and a second punch the same turn is refused.
|
||||||
|
const attacker = activePlayer(state);
|
||||||
|
const victim = state.players.find((p) => p.id !== attacker.id)!;
|
||||||
|
victim.position = { ...attacker.position };
|
||||||
|
const afterPunch = applyCommand(state, attacker.id, { type: "punch", targetId: victim.id });
|
||||||
|
expect(afterPunch.ok).toBe(true);
|
||||||
|
if (afterPunch.ok) {
|
||||||
|
const v = afterPunch.state.players.find((p) => p.id === victim.id)!;
|
||||||
|
expect(v.life).toBe(STARTING_LIFE - 1);
|
||||||
|
const secondPunch = applyCommand(afterPunch.state, attacker.id, {
|
||||||
|
type: "punch",
|
||||||
|
targetId: victim.id,
|
||||||
|
});
|
||||||
|
expect(secondPunch.ok).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cannot punch yourself", () => {
|
||||||
|
let { state } = newGame();
|
||||||
|
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
||||||
|
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
||||||
|
const me = activePlayer(state).id;
|
||||||
|
const result = applyCommand(state, me, { type: "punch", targetId: me });
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("kill transfers the dead player's cards and forces a discard to 7", () => {
|
||||||
|
let { state } = newGame();
|
||||||
|
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
||||||
|
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
||||||
|
const attacker = activePlayer(state);
|
||||||
|
const victim = state.players.find((p) => p.id !== attacker.id)!;
|
||||||
|
victim.position = { ...attacker.position };
|
||||||
|
victim.life = 1; // test surgery: one punch kills
|
||||||
|
const result = applyCommand(state, attacker.id, { type: "punch", targetId: victim.id });
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (!result.ok) return;
|
||||||
|
|
||||||
|
const s = result.state;
|
||||||
|
expect(s.players.find((p) => p.id === victim.id)!.alive).toBe(false);
|
||||||
|
const a = s.players.find((p) => p.id === attacker.id)!;
|
||||||
|
expect(a.hand.length).toBe(14);
|
||||||
|
expect(s.pendingDiscard).toBe(attacker.id);
|
||||||
|
// Game over check: 2 players, one dead -> last standing wins.
|
||||||
|
expect(s.phase).toBe("finished");
|
||||||
|
expect(s.winner).toBe(attacker.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("draws up to two cards at end of turn, respecting the hand limit", () => {
|
||||||
|
let { state } = newGame();
|
||||||
|
const me = activePlayer(state).id;
|
||||||
|
const deckBefore = state.deck.length;
|
||||||
|
// Hand is full (7): drawing 2 is capped to 0.
|
||||||
|
state = must(state, me, { type: "endTurn", draw: 2 });
|
||||||
|
expect(state.deck.length).toBe(deckBefore);
|
||||||
|
|
||||||
|
// Next player discards one, then ends turn drawing 2 -> capped to 1.
|
||||||
|
const p2 = activePlayer(state);
|
||||||
|
const discardOne = p2.hand[0]!.instanceId;
|
||||||
|
state = must(state, p2.id, { type: "discard", instanceIds: [discardOne] });
|
||||||
|
state = must(state, p2.id, { type: "endTurn", draw: 2 });
|
||||||
|
const p2After = state.players.find((p) => p.id === p2.id)!;
|
||||||
|
expect(p2After.hand.length).toBe(HAND_LIMIT);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("treasures and victory", () => {
|
||||||
|
it("stealing two enemy treasures to your home wins the game", () => {
|
||||||
|
let { state } = newGame();
|
||||||
|
const me = activePlayer(state);
|
||||||
|
const enemy = state.players.find((p) => p.id !== me.id)!;
|
||||||
|
const [t1, t2] = state.treasures.filter((t) => t.owner === enemy.id);
|
||||||
|
|
||||||
|
// Test surgery: place the wizard on an enemy treasure, pick it up (which
|
||||||
|
// ends the turn's actions), walk it home across turns via direct position
|
||||||
|
// edits (movement itself is tested elsewhere).
|
||||||
|
me.position = { ...t1!.position! };
|
||||||
|
state = must(state, me.id, { type: "pickUpTreasure" });
|
||||||
|
// Actions ended: further movement refused.
|
||||||
|
expect(applyCommand(state, me.id, { type: "move", direction: "N" }).ok).toBe(false);
|
||||||
|
state = must(state, me.id, { type: "endTurn", draw: 0 });
|
||||||
|
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
||||||
|
|
||||||
|
let p = state.players.find((p) => p.id === me.id)!;
|
||||||
|
p.position = { ...p.home };
|
||||||
|
state = must(state, me.id, { type: "dropTreasure" });
|
||||||
|
expect(state.phase).toBe("playing");
|
||||||
|
|
||||||
|
p = state.players.find((p) => p.id === me.id)!;
|
||||||
|
p.position = { ...t2!.position! };
|
||||||
|
state = must(state, me.id, { type: "pickUpTreasure" });
|
||||||
|
state = must(state, me.id, { type: "endTurn", draw: 0 });
|
||||||
|
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
||||||
|
|
||||||
|
p = state.players.find((p) => p.id === me.id)!;
|
||||||
|
p.position = { ...p.home };
|
||||||
|
state = must(state, me.id, { type: "dropTreasure" });
|
||||||
|
expect(state.phase).toBe("finished");
|
||||||
|
expect(state.winner).toBe(me.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only one treasure may be carried at a time", () => {
|
||||||
|
const { state } = newGame();
|
||||||
|
const me = activePlayer(state);
|
||||||
|
const enemy = state.players.find((p) => p.id !== me.id)!;
|
||||||
|
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:
|
||||||
|
const result = applyCommand(s1, me.id, { type: "pickUpTreasure" });
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"sourceMap": true
|
"sourceMap": true
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user