Eric spotted mismatches between the 5e-database-derived card data and his physical 6e cards (photos IMG_4688-4690). All 15 texts are now verbatim from the faces. Four were mechanical, now fixed in the engine: BLIND's 6e text adds "engage in combat" — blinded punches flail on a die roll; PICK LOCK's face states "This is not a spell" — the lock cards and thrown weapons are physical actions NO SPELL cannot silence; MIST-BODY passes doors and drifts through thornbushes but does NOT pass the maze's stone walls (was backwards); WALL OF FIRE's counteraction mode stops a Waterbolt (previously deferred). Metadata: UGLY is L.O.S.-marked, MASTER KEY is NEUTRAL/ADJACENT, WIZARDBLADE and PICK LOCK carry ADJACENT markings (new `adjacent` field), LARGE ROCK is rethrowable by any player (already engine behavior), BRAINSTONE's face has no corner type. 84 tests passing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
344 lines
15 KiB
TypeScript
344 lines
15 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
applyCommand,
|
|
activePlayer,
|
|
createGame,
|
|
boardView,
|
|
gameLos,
|
|
sustainedOn,
|
|
type Command,
|
|
type GameState,
|
|
type PlayerId,
|
|
} from "../src/game";
|
|
import { cellKey, edgeKey, neighbor, SIDES, stepTarget, type Cell, type Side } from "../src/board";
|
|
import type { CardInstance } from "../src/cards";
|
|
|
|
function newGame(seed = 42) {
|
|
return createGame({ playerIds: ["alice", "bob"], 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;
|
|
}
|
|
|
|
function giveCard(state: GameState, playerId: PlayerId, cardId: string, tag = "T", slot = 0): CardInstance {
|
|
const p = state.players.find((p) => p.id === playerId)!;
|
|
const instance = { instanceId: `${cardId}#${tag}`, cardId };
|
|
p.hand[slot] = instance;
|
|
return instance;
|
|
}
|
|
|
|
function toRound2(state: GameState): GameState {
|
|
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
|
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
|
return state;
|
|
}
|
|
|
|
function faceOff(state: GameState): { attacker: PlayerId; defender: PlayerId } {
|
|
const attacker = activePlayer(state);
|
|
const defender = state.players.find((p) => p.id !== attacker.id)!;
|
|
defender.position = { ...attacker.position };
|
|
return { attacker: attacker.id, defender: defender.id };
|
|
}
|
|
|
|
function castAt(
|
|
state: GameState, attacker: PlayerId, defender: PlayerId, card: CardInstance,
|
|
extra: Partial<Extract<Command, { type: "cast" }>> = {},
|
|
): GameState {
|
|
state = must(state, attacker, {
|
|
type: "cast", instanceId: card.instanceId,
|
|
target: { kind: "player", playerId: defender }, ...extra,
|
|
});
|
|
return must(state, defender, { type: "pass" });
|
|
}
|
|
|
|
/** An empty visible cell adjacent to the player (not home, no treasure). */
|
|
function emptyNeighborCell(state: GameState, of: Cell): { cell: Cell; side: Side } {
|
|
const view = boardView(state);
|
|
for (const side of SIDES) {
|
|
const t = stepTarget(view, of, side);
|
|
if (t.kind !== "step") continue;
|
|
const key = cellKey(t.to);
|
|
if (view.homes.some((h) => cellKey(h) === key)) continue;
|
|
if (state.treasures.some((tr) => tr.position && cellKey(tr.position) === key)) continue;
|
|
if (state.players.some((p) => cellKey(p.position) === key)) continue;
|
|
return { cell: t.to, side };
|
|
}
|
|
throw new Error("no empty neighbor");
|
|
}
|
|
|
|
describe("terrain", () => {
|
|
it("fill square with stone blocks movement and line of sight", () => {
|
|
let { state } = newGame();
|
|
const me = activePlayer(state);
|
|
const spot = emptyNeighborCell(state, me.position);
|
|
const fs = giveCard(state, me.id, "fill-square-with-stone");
|
|
state = must(state, me.id, {
|
|
type: "cast", instanceId: fs.instanceId, target: { kind: "cell", cell: spot.cell },
|
|
});
|
|
expect(applyCommand(state, me.id, { type: "move", direction: spot.side }).ok).toBe(false);
|
|
// LOS straight through the stone is blocked.
|
|
const beyond = neighbor(spot.cell, spot.side);
|
|
if (boardView(state).cells[cellKey(beyond)]) {
|
|
expect(gameLos(state, activePlayer(state).position, beyond)).toBe(false);
|
|
}
|
|
});
|
|
|
|
it("thornbush entry costs a life point, the turn, and the next turn", () => {
|
|
let { state } = newGame();
|
|
const me = activePlayer(state);
|
|
const spot = emptyNeighborCell(state, me.position);
|
|
const tb = giveCard(state, me.id, "thornbush");
|
|
state = must(state, me.id, {
|
|
type: "cast", instanceId: tb.instanceId, target: { kind: "cell", cell: spot.cell },
|
|
});
|
|
state = must(state, me.id, { type: "move", direction: spot.side });
|
|
const p = state.players.find((p) => p.id === me.id)!;
|
|
expect(p.life).toBe(14);
|
|
expect(p.lostTurns).toBe(1);
|
|
expect(state.turn.actionsEnded).toBe(true);
|
|
});
|
|
|
|
it("wizards in a thornbush cannot attack or be attacked", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const { attacker, defender } = faceOff(state);
|
|
const d = state.players.find((p) => p.id === defender)!;
|
|
// Test surgery: plant a bush and stand the defender in it.
|
|
state.squareContents[cellKey(d.position)] = { kind: "thornbush", damage: 0, createdBy: attacker };
|
|
const fb = giveCard(state, attacker, "fireball");
|
|
const refused = applyCommand(state, attacker, {
|
|
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender },
|
|
});
|
|
expect(refused.ok).toBe(false);
|
|
});
|
|
|
|
it("wall of fire burns crossers and expires with its duration", () => {
|
|
let { state } = newGame();
|
|
const me = activePlayer(state);
|
|
const spot = emptyNeighborCell(state, me.position);
|
|
const key = edgeKey(me.position, spot.side);
|
|
const wof = giveCard(state, me.id, "wall-of-fire");
|
|
giveCard(state, me.id, "number-2", "N", 1);
|
|
state = must(state, me.id, {
|
|
type: "cast", instanceId: wof.instanceId, numberInstanceIds: ["number-2#N"],
|
|
target: { kind: "edge", cell: me.position, side: spot.side },
|
|
});
|
|
expect(boardView(state).edges[key]).toBe("firewall");
|
|
|
|
// Walking through it hurts.
|
|
state = must(state, me.id, { type: "move", direction: spot.side });
|
|
expect(state.players.find((p) => p.id === me.id)!.life).toBe(11);
|
|
|
|
// Duration 2: expires at the start of the caster's second following turn.
|
|
state = must(state, me.id, { type: "endTurn", draw: 0 });
|
|
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
|
expect(boardView(state).edges[key]).toBe("firewall"); // 1 turn left
|
|
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
|
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
|
expect(boardView(state).edges[key]).toBeUndefined();
|
|
});
|
|
|
|
it("dispel creation removes a created wall", () => {
|
|
let { state } = newGame();
|
|
const me = activePlayer(state);
|
|
const spot = emptyNeighborCell(state, me.position);
|
|
const key = edgeKey(me.position, spot.side);
|
|
const cw = giveCard(state, me.id, "create-wall");
|
|
state = must(state, me.id, {
|
|
type: "cast", instanceId: cw.instanceId,
|
|
target: { kind: "edge", cell: me.position, side: spot.side },
|
|
});
|
|
expect(boardView(state).edges[key]).toBe("wall");
|
|
const dc = giveCard(state, me.id, "dispel-creation");
|
|
state = must(state, me.id, {
|
|
type: "cast", instanceId: dc.instanceId,
|
|
target: { kind: "edge", cell: me.position, side: spot.side },
|
|
});
|
|
expect(boardView(state).edges[key]).toBeUndefined();
|
|
|
|
// But a printed (original) wall cannot be dispelled.
|
|
const dc2 = giveCard(state, me.id, "dispel-creation", "T2");
|
|
const view = boardView(state);
|
|
const wallEntry = Object.entries(view.edges).find(([, s]) => s === "wall")!;
|
|
const [kind, coords] = wallEntry[0].split(":") as [string, string];
|
|
const [x, y] = coords.split(",").map(Number) as [number, number];
|
|
const refused = applyCommand(state, me.id, {
|
|
type: "cast", instanceId: dc2.instanceId,
|
|
target: { kind: "edge", cell: { x, y }, side: kind === "V" ? "E" : "S" },
|
|
});
|
|
expect(refused.ok).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("objects", () => {
|
|
it("a thrown dagger does physical damage full shield cannot stop, then lies on the floor", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const { attacker, defender } = faceOff(state);
|
|
const dagger = giveCard(state, attacker, "dagger");
|
|
giveCard(state, defender, "full-shield", "FS", 0);
|
|
state = must(state, attacker, {
|
|
type: "cast", instanceId: dagger.instanceId, target: { kind: "player", playerId: defender },
|
|
});
|
|
state = must(state, defender, { type: "counteract", instanceId: "full-shield#FS" });
|
|
state = must(state, attacker, { type: "pass" });
|
|
state = must(state, defender, { type: "pass" });
|
|
const d = state.players.find((p) => p.id === defender)!;
|
|
expect(d.life).toBe(12); // full shield "does not stop any physical attack"
|
|
const floor = state.groundObjects[cellKey(d.position)] ?? [];
|
|
expect(floor.some((c) => c.cardId === "dagger")).toBe(true);
|
|
|
|
// Anyone may pick it up — and doing so ends the turn's actions.
|
|
state = must(state, attacker, { type: "endTurn", draw: 0 });
|
|
state = must(state, defender, { type: "pickUpObject", instanceId: dagger.instanceId });
|
|
expect(state.players.find((p) => p.id === defender)!.hand.some((c) => c.cardId === "dagger")).toBe(true);
|
|
expect(state.turn.actionsEnded).toBe(true);
|
|
});
|
|
|
|
it("blunt halves a thrown rock's physical damage", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const { attacker, defender } = faceOff(state);
|
|
const rock = giveCard(state, attacker, "large-rock");
|
|
giveCard(state, defender, "blunt", "B", 0);
|
|
state = must(state, attacker, {
|
|
type: "cast", instanceId: rock.instanceId, target: { kind: "player", playerId: defender },
|
|
});
|
|
state = must(state, defender, { type: "counteract", instanceId: "blunt#B" });
|
|
state = must(state, attacker, { type: "pass" });
|
|
state = must(state, defender, { type: "pass" });
|
|
expect(state.players.find((p) => p.id === defender)!.life).toBe(14); // ceil(2/2)=1
|
|
});
|
|
|
|
it("drop object forces a named object to the floor; drag pulls a treasure home", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const { attacker, defender } = faceOff(state);
|
|
giveCard(state, defender, "dagger", "D2", 0);
|
|
const dobj = giveCard(state, attacker, "drop-object");
|
|
state = castAt(state, attacker, defender, dobj, { params: { cardId: "dagger" } });
|
|
const dPos = state.players.find((p) => p.id === defender)!.position;
|
|
expect((state.groundObjects[cellKey(dPos)] ?? []).some((c) => c.cardId === "dagger")).toBe(true);
|
|
|
|
// Drag an enemy treasure across open floor toward the caster.
|
|
state = must(state, attacker, { type: "endTurn", draw: 0 });
|
|
state = must(state, defender, { type: "endTurn", draw: 0 });
|
|
const enemyTreasure = state.treasures.find((t) => t.owner === defender && t.position)!;
|
|
const me2 = state.players.find((p) => p.id === attacker)!;
|
|
// Stand adjacent-ish to the treasure with clear LOS: same square works.
|
|
me2.position = { ...enemyTreasure.position! };
|
|
const drag = giveCard(state, attacker, "drag");
|
|
state = must(state, attacker, {
|
|
type: "cast", instanceId: drag.instanceId,
|
|
target: { kind: "cell", cell: enemyTreasure.position! },
|
|
});
|
|
const t = state.treasures.find((tr) => tr.id === enemyTreasure.id)!;
|
|
expect(cellKey(t.position!)).toBe(cellKey(state.players.find((p) => p.id === attacker)!.position));
|
|
});
|
|
});
|
|
|
|
describe("control effects", () => {
|
|
it("lock in place stops moving and being moved", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const { attacker, defender } = faceOff(state);
|
|
const lip = giveCard(state, attacker, "lock-in-place");
|
|
giveCard(state, attacker, "number-3", "N", 1);
|
|
state = castAt(state, attacker, defender, lip, { numberInstanceIds: ["number-3#N"] });
|
|
expect(sustainedOn(state, defender, "lock-in-place").length).toBe(1);
|
|
|
|
state = must(state, attacker, { type: "endTurn", draw: 0 });
|
|
expect(applyCommand(state, defender, { type: "move", direction: "N" }).ok).toBe(false);
|
|
state = must(state, defender, { type: "endTurn", draw: 0 });
|
|
|
|
// Teleport Opponent fizzles against a locked target.
|
|
const tpo = giveCard(state, attacker, "teleport-opponent");
|
|
const before = state.players.find((p) => p.id === defender)!.position;
|
|
const anywhere = state.board.homes.find((h) => cellKey(h) !== cellKey(before))!;
|
|
state = castAt(state, attacker, defender, tpo, { params: { cell: anywhere } });
|
|
expect(cellKey(state.players.find((p) => p.id === defender)!.position)).toBe(cellKey(before));
|
|
});
|
|
|
|
it("buddy prevents attacks until the caster breaks the pact", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const { attacker, defender } = faceOff(state);
|
|
const buddy = giveCard(state, attacker, "buddy");
|
|
state = must(state, attacker, {
|
|
type: "cast", instanceId: buddy.instanceId, target: { kind: "player", playerId: defender },
|
|
});
|
|
state = must(state, attacker, { type: "endTurn", draw: 0 });
|
|
|
|
// The defender cannot bring themselves to attack the caster.
|
|
const fb = giveCard(state, defender, "fireball", "F", 0);
|
|
const refused = applyCommand(state, defender, {
|
|
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: attacker },
|
|
});
|
|
expect(refused.ok).toBe(false);
|
|
state = must(state, defender, { type: "endTurn", draw: 0 });
|
|
|
|
// The caster attacks first: the pact breaks; next turn the defender may.
|
|
const fb2 = giveCard(state, attacker, "fireball", "F2", 0);
|
|
state = castAt(state, attacker, defender, fb2);
|
|
expect(state.sustained.some((s) => s.cardId === "buddy")).toBe(false);
|
|
});
|
|
|
|
it("mist body passes through walls but cannot attack or be attacked", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const { attacker, defender } = faceOff(state);
|
|
const mist = giveCard(state, defender, "mist-body", "M", 0);
|
|
giveCard(state, defender, "number-3", "N", 1);
|
|
state = must(state, attacker, { type: "endTurn", draw: 0 });
|
|
state = must(state, defender, {
|
|
type: "cast", instanceId: mist.instanceId, numberInstanceIds: ["number-3#N"],
|
|
});
|
|
|
|
// Mist passes through locked DOORS but NOT the maze's stone walls.
|
|
{
|
|
const view = boardView(state);
|
|
const d = state.players.find((p) => p.id === defender)!;
|
|
const doorEntry = Object.entries(view.edges).find(([, st]) => st === "door")!;
|
|
const [kind, coords] = doorEntry[0].split(":") as [string, string];
|
|
const [dx, dy] = coords.split(",").map(Number) as [number, number];
|
|
d.position = { x: dx, y: dy };
|
|
state = must(state, defender, { type: "move", direction: kind === "V" ? "E" : "S" });
|
|
for (const side of SIDES) {
|
|
const k = edgeKey(state.players.find((p) => p.id === defender)!.position, side);
|
|
const view2 = boardView(state);
|
|
if (view2.edges[k] === "wall" &&
|
|
view2.cells[cellKey(neighbor(state.players.find((p) => p.id === defender)!.position, side))]) {
|
|
expect(applyCommand(state, defender, { type: "move", direction: side }).ok).toBe(false);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
state = must(state, defender, { type: "endTurn", draw: 0 });
|
|
|
|
const fb = giveCard(state, attacker, "fireball", "F", 0);
|
|
const dd = state.players.find((p) => p.id === defender)!;
|
|
state.players.find((p) => p.id === attacker)!.position = { ...dd.position };
|
|
const refused = applyCommand(state, attacker, {
|
|
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender },
|
|
});
|
|
expect(refused.ok).toBe(false);
|
|
});
|
|
|
|
it("reuse spell retrieves the last spell you cast", () => {
|
|
let { state } = newGame();
|
|
const me = activePlayer(state);
|
|
const spot = emptyNeighborCell(state, me.position);
|
|
const cw = giveCard(state, me.id, "create-wall");
|
|
state = must(state, me.id, {
|
|
type: "cast", instanceId: cw.instanceId,
|
|
target: { kind: "edge", cell: me.position, side: spot.side },
|
|
});
|
|
const ru = giveCard(state, me.id, "reuse-spell");
|
|
state = must(state, me.id, { type: "cast", instanceId: ru.instanceId });
|
|
expect(state.players.find((p) => p.id === me.id)!.hand.some((c) => c.cardId === "create-wall")).toBe(true);
|
|
});
|
|
});
|