Blind reviews over the rev-37..40 features and the gate collapse. The scripted surgery's scars come out: bare brace blocks unwrapped (with one hiding an unreachable scrambleHands), the amputated comment healed, the dead events parameter unthreaded from the illusion-sight chain, the laziness probe in perceivedBoard collapsed to the rule it always produced, GameView.deckRev retired with its last reader, the ward's arming stub told honestly, and every comment or test title still citing the retired revision numbering reworded to the rule it guards. Rim mechanics unify on rimWarpMouth and breachRim; the test suite gains plainRimWall and pushSustained and loses its loop-shaped scars and rev-named helpers. No behavior changes; 255 tests pass unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
364 lines
16 KiB
TypeScript
364 lines
16 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { giveCard } from "./helpers";
|
|
import { edgeKey } from "../src/board";
|
|
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", () => {
|
|
it("the carried treasure lands where the wizard stood", () => {
|
|
let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 42, sets: ["basic"] });
|
|
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", () => {
|
|
function grabRig() {
|
|
let { state } = createGame({ playerIds: ["thief", "owner"], seed: 42, sets: ["basic"] });
|
|
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 ahead is refused — the ward waits for the grab", () => {
|
|
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] });
|
|
giveCard(state, state.players[state.turn.activeIndex]!.id, "ward", "W", 0);
|
|
const r = applyCommand(state, state.players[state.turn.activeIndex]!.id, { type: "armWard" });
|
|
expect(r.ok).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("an ambushed teleport carries its destination", () => {
|
|
it("the trap springs and the victim lands where the trapper said", () => {
|
|
let { state } = createGame({ playerIds: ["trapper", "prey"], seed: 42, sets: ["basic", "expansion1"] });
|
|
for (let guard = 0; guard < 10 && !(state.players[state.turn.activeIndex]!.id === "trapper" && state.turn.round > 1); guard++) {
|
|
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 trapper = state.players.find((p) => p.id === "trapper")!;
|
|
const prey = state.players.find((p) => p.id === "prey")!;
|
|
giveCard(state, "trapper", "interrupt", "I", 0);
|
|
giveCard(state, "trapper", "teleport-opponent", "TO", 1);
|
|
// Arming without a destination is refused; with one it is stored.
|
|
const bad = applyCommand(state, "trapper", {
|
|
type: "setAmbush", instanceId: "interrupt#I", trigger: { kind: "near" },
|
|
spellInstanceId: "teleport-opponent#TO",
|
|
});
|
|
expect(bad.ok).toBe(false);
|
|
const dest = { x: trapper.home.x, y: trapper.home.y === 0 ? 1 : trapper.home.y - 1 };
|
|
let r = applyCommand(state, "trapper", {
|
|
type: "setAmbush", instanceId: "interrupt#I", trigger: { kind: "near" },
|
|
spellInstanceId: "teleport-opponent#TO", cell: dest,
|
|
});
|
|
if (!r.ok) throw new Error(r.error);
|
|
state = r.state;
|
|
r = applyCommand(state, "trapper", { type: "endTurn", draw: 0 });
|
|
if (!r.ok) throw new Error(r.error);
|
|
state = r.state;
|
|
// The prey walks adjacent; the trap springs; the prey passes; they land
|
|
// at dest. (Re-find both: applyCommand clones made the handles stale.)
|
|
const trapperNow = state.players.find((p) => p.id === "trapper")!;
|
|
const preyNow = state.players.find((p) => p.id === "prey")!;
|
|
const below = trapperNow.position.y >= 2;
|
|
preyNow.position = { x: trapperNow.position.x, y: trapperNow.position.y + (below ? -2 : 2) };
|
|
const dir = below ? "S" : "N";
|
|
state.edgeOverrides[edgeKey(preyNow.position, dir)] = "open";
|
|
r = applyCommand(state, "prey", { type: "move", direction: dir });
|
|
if (!r.ok) throw new Error(r.error);
|
|
state = r.state;
|
|
expect(state.stack?.attackCard?.cardId).toBe("teleport-opponent");
|
|
expect(state.stack?.params?.cell).toEqual(dest);
|
|
r = applyCommand(state, "prey", { type: "pass" });
|
|
if (!r.ok) throw new Error(r.error);
|
|
state = r.state;
|
|
expect(state.players.find((p) => p.id === "prey")!.position).toEqual(dest);
|
|
});
|
|
});
|