pickUpTreasure grabbed whichever treasure the list offered first — with two on a square, no choice and sometimes the wrong one. The command now takes an optional treasureId (absent in every stored ledger, so old replays keep their old grab), and when the square holds more than one, the button asks "whose?" before dispatching. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
294 lines
14 KiB
TypeScript
294 lines
14 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { applyCommand, activePlayer, boardView, createGame, gameLos, sustainedOn } from "../src/game";
|
|
import { cellKey, edgeKey, neighbor, SIDES } from "../src/board";
|
|
import type { CardInstance } from "../src/cards";
|
|
import { newGame, must, giveCard, toRound2, faceOff, castAt, emptyNeighborCell } from "./helpers";
|
|
|
|
/** An empty visible cell adjacent to the player (not home, no treasure). */
|
|
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);
|
|
});
|
|
});
|
|
|
|
describe("picking one of two treasures", () => {
|
|
it("a named treasure is the one taken", () => {
|
|
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 16 });
|
|
state = toRound2(state);
|
|
const p = activePlayer(state);
|
|
const [t1, t2] = state.treasures.filter((t) => t.owner !== p.id);
|
|
t1!.position = { ...p.position }; t1!.carriedBy = null;
|
|
t2!.position = { ...p.position }; t2!.carriedBy = null;
|
|
const r = applyCommand(state, p.id, { type: "pickUpTreasure", treasureId: t2!.id });
|
|
if (!r.ok) throw new Error(r.error);
|
|
expect(r.state.players.find((q) => q.id === p.id)!.carriedTreasureId).toBe(t2!.id);
|
|
});
|
|
});
|