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,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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user