Files
wizwar6e/packages/engine/test/game.test.ts
T
Eric WagonerandClaude Fable 5 75d3d44c6c The Ward is played in the moment (rules rev 31)
'When a player picks up one of your treasures, you may play at that
time (out of turn) this card on him' — the owner read the card and is
right: no arming ahead. When a treasure whose owner holds WARD is
grabbed, the grab hangs (new wardPending phase, outranking all other
input) and the owner alone answers: spring it (3 damage, card spent)
or let them go (card kept, silence). Automatons always spring it on a
thief of their gold. The arming mechanic is refused at rev 31 and its
button hidden; rev 3-30 games keep their armed wards and rev 1-2 keep
the automatic spring, replaying unchanged. The window does reveal that
the owner holds SOMETHING - as it would at any real table when the
room turns to look at them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-19 20:17:43 -04:00

315 lines
14 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { giveCard } from "./helpers";
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 };
state = must(state, attacker.id, { type: "punch", targetId: victim.id });
// Punches enter the counteraction stack (BLUNT/ABSORB work on physical
// damage); the defender passes and the punch resolves.
state = must(state, victim.id, { type: "pass" });
const v = state.players.find((p) => p.id === victim.id)!;
expect(v.life).toBe(STARTING_LIFE - 1);
const secondPunch = applyCommand(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
state = must(state, attacker.id, { type: "punch", targetId: victim.id });
state = must(state, victim.id, { type: "pass" });
const s = 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" });
// Stand on the second treasure: the pickup is still refused ("one at a time").
s1.players.find((p) => p.id === me.id)!.position = { ...t2!.position! };
const result = applyCommand(s1, me.id, { type: "pickUpTreasure" });
expect(result.ok).toBe(false);
});
});
describe("elimination by lost treasures drops what the fallen carried (rev 30)", () => {
it("the carried treasure lands where the wizard stood", () => {
let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 42, sets: ["basic"], deckRev: 30 });
const A = state.players.find((p) => p.id === "a")!;
const B = state.players.find((p) => p.id === "b")!;
const C = state.players.find((p) => p.id === "c")!;
// C's first treasure already sits captured on B's home...
const c1 = state.treasures.filter((t) => t.owner === "c")[0]!;
c1.position = { ...B.home };
// ...C carries one of B's treasures...
const bt = state.treasures.filter((t) => t.owner === "b")[0]!;
bt.position = null;
bt.carriedBy = "c";
C.carriedTreasureId = bt.id;
// ...and A, standing on their own home, drops C's second treasure there.
const c2 = state.treasures.filter((t) => t.owner === "c")[1]!;
c2.position = null;
c2.carriedBy = "a";
A.carriedTreasureId = c2.id;
A.position = { ...A.home };
while (state.players[state.turn.activeIndex]!.id !== "a") {
const r = applyCommand(state, state.players[state.turn.activeIndex]!.id, { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(r.error);
state = r.state;
}
const r = applyCommand(state, "a", { type: "dropTreasure" });
if (!r.ok) throw new Error(r.error);
state = r.state;
// C is eliminated by lost treasures — and B's treasure lies where C stood.
const cAfter = state.players.find((p) => p.id === "c")!;
expect(cAfter.alive).toBe(false);
expect(cAfter.carriedTreasureId).toBeNull();
const btAfter = state.treasures.find((t) => t.id === bt.id)!;
expect(btAfter.carriedBy).toBeNull();
expect(btAfter.position).toEqual(cAfter.position);
});
});
describe("the Ward is played in the moment (rules rev 31)", () => {
function grabRig() {
let { state } = createGame({ playerIds: ["thief", "owner"], seed: 42, sets: ["basic"], deckRev: 31 });
const thief = state.players.find((p) => p.id === "thief")!;
const owner = state.players.find((p) => p.id === "owner")!;
giveCard(state, "owner", "ward", "W", 0);
const t = state.treasures.find((t) => t.owner === "owner" && t.position)!;
thief.position = { ...t.position! };
while (state.players[state.turn.activeIndex]!.id !== "thief") {
const r = applyCommand(state, state.players[state.turn.activeIndex]!.id, { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(r.error);
state = r.state;
}
const r = applyCommand(state, "thief", { type: "pickUpTreasure" });
if (!r.ok) throw new Error(r.error);
return { state: r.state, owner, thief };
}
it("the grab hangs on the owner; springing costs the thief 3 and the card", () => {
let { state } = grabRig();
expect(state.wardPending).toEqual({ ownerId: "owner", takerId: "thief" });
// Nobody else may act while it hangs.
expect(applyCommand(state, "thief", { type: "endTurn", draw: 0 }).ok).toBe(false);
const r = applyCommand(state, "owner", { type: "wardChoice", play: true });
if (!r.ok) throw new Error(r.error);
state = r.state;
expect(state.wardPending).toBeNull();
expect(state.players.find((p) => p.id === "thief")!.life).toBe(12);
expect(state.players.find((p) => p.id === "owner")!.hand.some((c) => c.cardId === "ward")).toBe(false);
});
it("declining lets the thief go, Ward still in hand", () => {
let { state } = grabRig();
const r = applyCommand(state, "owner", { type: "wardChoice", play: false });
if (!r.ok) throw new Error(r.error);
state = r.state;
expect(state.players.find((p) => p.id === "thief")!.life).toBe(15);
expect(state.players.find((p) => p.id === "owner")!.hand.some((c) => c.cardId === "ward")).toBe(true);
expect(applyCommand(state, "thief", { type: "endTurn", draw: 0 }).ok).toBe(true);
});
it("arming is refused in this vintage", () => {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 31 });
giveCard(state, state.players[state.turn.activeIndex]!.id, "ward", "W", 0);
const r = applyCommand(state, state.players[state.turn.activeIndex]!.id, { type: "armWard", armed: true });
expect(r.ok).toBe(false);
});
});