diff --git a/packages/engine/src/board.ts b/packages/engine/src/board.ts new file mode 100644 index 0000000..edf4367 --- /dev/null +++ b/packages/engine/src/board.ts @@ -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; + /** Cells that are part of the map (all cells inside placed sectors). */ + cells: Record; + 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 = 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 = {}; + const cells: Record = {}; + 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) + ); +} diff --git a/packages/engine/src/cards.ts b/packages/engine/src/cards.ts new file mode 100644 index 0000000..d0d6294 --- /dev/null +++ b/packages/engine/src/cards.ts @@ -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"; +} diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts new file mode 100644 index 0000000..f095d0f --- /dev/null +++ b/packages/engine/src/game.ts @@ -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; 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 = 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(); + 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 }; +} diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 7d406f6..ff7a43e 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -1,32 +1,10 @@ -// @wizwar/engine — pure, deterministic game logic. No I/O, no timers, no -// network: the server drives it in real time, the async mode replays it from -// stored commands, and future AI players call it to evaluate moves. -// -// Card data, board layouts, and precise rule mechanics are pending -// transcription of the 6th edition materials (see /research). +// @wizwar/engine — pure, deterministic Wiz-War (6th edition) game logic. +// No I/O, no timers, no network: the server drives it in real time, async +// 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. -export interface PlayerId { - readonly id: string; -} - -/** A single square on a sector board. Walls live on edges between squares. */ -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" }; +export * from "./rng"; +export * from "./board"; +export * from "./cards"; +export * from "./setups"; +export * from "./game"; diff --git a/packages/engine/src/rng.ts b/packages/engine/src/rng.ts new file mode 100644 index 0000000..dfb27c8 --- /dev/null +++ b/packages/engine/src/rng.ts @@ -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(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]; +} diff --git a/packages/engine/src/setups.ts b/packages/engine/src/setups.ts new file mode 100644 index 0000000..578503a --- /dev/null +++ b/packages/engine/src/setups.ts @@ -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)`); +} diff --git a/packages/engine/test/board.test.ts b/packages/engine/test/board.test.ts new file mode 100644 index 0000000..5562017 --- /dev/null +++ b/packages/engine/test/board.test.ts @@ -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); + }); +}); diff --git a/packages/engine/test/game.test.ts b/packages/engine/test/game.test.ts new file mode 100644 index 0000000..31afdce --- /dev/null +++ b/packages/engine/test/game.test.ts @@ -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); + }); +}); diff --git a/tsconfig.base.json b/tsconfig.base.json index c9106fc..51530a1 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -8,6 +8,7 @@ "forceConsistentCasingInFileNames": true, "skipLibCheck": true, "esModuleInterop": true, + "resolveJsonModule": true, "declaration": true, "sourceMap": true }