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
+86
View File
@@ -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";
}