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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-15 19:39:50 -04:00
co-authored by Claude Fable 5
parent 11209cdc5d
commit a8884592a4
9 changed files with 1428 additions and 31 deletions
+571
View File
@@ -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 };
}