KILLER OOZE (1 damage on entry, slip on 1-2: drop treasure, 2 more, flat on your face until you roll to stand), CREATE PIT (jumps roll a D4 — clear it on 2-4, fall in on a 1 and climb out on later rolls), ROSEBUSH (3-point passage, blocks sight), DUST CLOUD (a permanent zone of blindness: direction rolls inside, no LOS through, defeats Visionstone), FILL SQUARE WITH SLIME (entering ends your actions, blocks sight), HANDFUL OF TACKS (adjacent-only scatter, 3 points to cross), CREATE DOOR (a new locked door in any wall or corridor), BOOBYTRAP (four face-down tokens, one secretly real — 4 points under anyone but the caster; the caster's view alone marks the true token), GLUE (objects pinned for twice the number card), SAFE (items locked away from everyone but the creator), TRADER (swap two floor items in sight; glue and safes hold fast), and STONE TO WATER (walls melt into range-2 waves, stone blocks into range-4 bursts). LOS blocking is now per-terrain-kind. The board renders all of it, boobytrap placement is a four-click ritual, and Trader is two. 108 tests passing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
207 lines
8.8 KiB
TypeScript
207 lines
8.8 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
applyCommand,
|
|
activePlayer,
|
|
createGame,
|
|
boardView,
|
|
gameLos,
|
|
type Command,
|
|
type GameState,
|
|
type PlayerId,
|
|
} from "../src/game";
|
|
import { cellKey, 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", "expansion1"] });
|
|
}
|
|
|
|
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 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("expansion terrain", () => {
|
|
it("killer ooze burns on entry and can drop you on your face", () => {
|
|
// Across seeds we should see both slips and clean crossings.
|
|
let slips = 0, crossings = 0;
|
|
for (let seed = 1; seed <= 10; seed++) {
|
|
let { state } = newGame(seed);
|
|
const me = activePlayer(state);
|
|
const spot = emptyNeighborCell(state, me.position);
|
|
const oz = giveCard(state, me.id, "killer-ooze");
|
|
state = must(state, me.id, {
|
|
type: "cast", instanceId: oz.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)!;
|
|
if (p.fallenInOoze) { slips++; expect(p.life).toBe(12); } // 1 + 2
|
|
else { crossings++; expect(p.life).toBe(14); }
|
|
}
|
|
expect(slips + crossings).toBe(10);
|
|
expect(slips).toBeGreaterThan(0);
|
|
expect(crossings).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("a pit is jumped on 2-4 and fallen into on a 1", () => {
|
|
let falls = 0, jumps = 0, teeters = 0;
|
|
for (let seed = 1; seed <= 12; seed++) {
|
|
let { state } = newGame(seed);
|
|
const me = activePlayer(state);
|
|
const spot = emptyNeighborCell(state, me.position);
|
|
const pit = giveCard(state, me.id, "create-pit");
|
|
state = must(state, me.id, {
|
|
type: "cast", instanceId: pit.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)!;
|
|
if (p.inPit) { falls++; expect(p.life).toBe(13); }
|
|
else if (cellKey(p.position) === cellKey(me.position)) teeters++;
|
|
else jumps++;
|
|
}
|
|
expect(falls + jumps + teeters).toBe(12);
|
|
expect(falls).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("dust cloud blinds anyone standing inside it", () => {
|
|
let { state } = newGame();
|
|
const me = activePlayer(state);
|
|
const spot = emptyNeighborCell(state, me.position);
|
|
const dc = giveCard(state, me.id, "dust-cloud");
|
|
state = must(state, me.id, {
|
|
type: "cast", instanceId: dc.instanceId, target: { kind: "cell", cell: spot.cell },
|
|
});
|
|
// LOS through the cloud is blocked.
|
|
const view = boardView(state);
|
|
const beyond = { x: spot.cell.x + (spot.cell.x - me.position.x), y: spot.cell.y + (spot.cell.y - me.position.y) };
|
|
if (view.cells[cellKey(beyond)]) {
|
|
expect(gameLos(state, activePlayer(state).position, beyond)).toBe(false);
|
|
}
|
|
});
|
|
|
|
it("glue pins a treasure to the floor until it wears off", () => {
|
|
let { state } = newGame();
|
|
const me = activePlayer(state);
|
|
const treasure = state.treasures.find((t) => t.position && t.owner !== me.id)!;
|
|
me.position = { ...treasure.position! };
|
|
const gl = giveCard(state, me.id, "glue");
|
|
giveCard(state, me.id, "number-2", "N", 1);
|
|
state = must(state, me.id, {
|
|
type: "cast", instanceId: gl.instanceId, numberInstanceIds: ["number-2#N"],
|
|
target: { kind: "cell", cell: treasure.position! },
|
|
});
|
|
expect(applyCommand(state, me.id, { type: "pickUpTreasure" }).ok).toBe(false);
|
|
// 2 x 2 = 4 of the caster's turns later, the glue dries out.
|
|
for (let i = 0; i < 8; i++) {
|
|
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
|
}
|
|
state.players.find((p) => p.id === me.id)!.position = { ...treasure.position! };
|
|
expect(applyCommand(state, me.id, { type: "pickUpTreasure" }).ok).toBe(true);
|
|
});
|
|
|
|
it("a safe locks a treasure away from everyone but its creator", () => {
|
|
let { state } = newGame();
|
|
const me = activePlayer(state);
|
|
const enemy = state.players.find((p) => p.id !== me.id)!;
|
|
const treasure = state.treasures.find((t) => t.position && t.owner === enemy.id)!;
|
|
me.position = { ...treasure.position! };
|
|
const sf = giveCard(state, me.id, "safe");
|
|
state = must(state, me.id, {
|
|
type: "cast", instanceId: sf.instanceId, target: { kind: "cell", cell: treasure.position! },
|
|
});
|
|
// The enemy cannot take it...
|
|
state = must(state, me.id, { type: "endTurn", draw: 0 });
|
|
const e = state.players.find((p) => p.id === enemy.id)!;
|
|
e.position = { ...treasure.position! };
|
|
expect(applyCommand(state, enemy.id, { type: "pickUpTreasure" }).ok).toBe(false);
|
|
// ...but the creator knows the combination.
|
|
state = must(state, enemy.id, { type: "endTurn", draw: 0 });
|
|
state.players.find((p) => p.id === me.id)!.position = { ...treasure.position! };
|
|
expect(applyCommand(state, me.id, { type: "pickUpTreasure" }).ok).toBe(true);
|
|
});
|
|
|
|
it("boobytrap detonates only under its real token, never under the caster", () => {
|
|
let { state } = newGame();
|
|
const me = activePlayer(state);
|
|
const enemy = state.players.find((p) => p.id !== me.id)!;
|
|
// Four distinct empty-ish cells: use home-adjacent floor cells of the board.
|
|
const view = boardView(state);
|
|
const open: Cell[] = [];
|
|
for (const key of Object.keys(view.cells)) {
|
|
const [x, y] = key.split(",").map(Number) as [number, number];
|
|
const c = { x, y };
|
|
if (state.squareContents[key]) continue;
|
|
open.push(c);
|
|
if (open.length === 4) break;
|
|
}
|
|
const bt = giveCard(state, me.id, "boobytrap");
|
|
state = must(state, me.id, {
|
|
type: "cast", instanceId: bt.instanceId, params: { cells: open },
|
|
});
|
|
// Caster strolls across the real token unharmed.
|
|
const caster = state.players.find((p) => p.id === me.id)!;
|
|
caster.position = { ...open[0]! };
|
|
// (position set directly — trap only triggers on a move; simulate enemy)
|
|
state = must(state, me.id, { type: "endTurn", draw: 0 });
|
|
const e = state.players.find((p) => p.id === enemy.id)!;
|
|
// Stand the enemy adjacent to the real token and step onto it.
|
|
for (const side of SIDES) {
|
|
const from = { x: open[0]!.x + (side === "E" ? -1 : side === "W" ? 1 : 0),
|
|
y: open[0]!.y + (side === "S" ? -1 : side === "N" ? 1 : 0) };
|
|
if (!view.cells[cellKey(from)]) continue;
|
|
const t = stepTarget(view, from, side);
|
|
if (t.kind === "step" && cellKey(t.to) === cellKey(open[0]!)) {
|
|
e.position = from;
|
|
const result = applyCommand(state, enemy.id, { type: "move", direction: side });
|
|
if (result.ok) {
|
|
state = result.state;
|
|
const hurt = state.players.find((p) => p.id === enemy.id)!;
|
|
expect(hurt.life).toBeLessThanOrEqual(11);
|
|
expect(state.boobytraps.length).toBe(0);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
});
|
|
|
|
it("stone to water melts a stone block into a crashing wave", () => {
|
|
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 },
|
|
});
|
|
const stw = giveCard(state, me.id, "stone-to-water", "SW", 1);
|
|
state = must(state, me.id, {
|
|
type: "cast", instanceId: stw.instanceId, target: { kind: "cell", cell: spot.cell },
|
|
});
|
|
expect(state.squareContents[cellKey(spot.cell)]).toBeUndefined();
|
|
// The caster stood beside the block: the wave washed them somewhere (or
|
|
// crushed them for blocked spaces) — either way life or position changed
|
|
// is acceptable; assert no crash and the block is gone.
|
|
});
|
|
});
|