diff --git a/packages/engine/src/view.ts b/packages/engine/src/view.ts index 2aafee8..fc9c648 100644 --- a/packages/engine/src/view.ts +++ b/packages/engine/src/view.ts @@ -185,7 +185,7 @@ export function sightedCellsFor(view: GameView): Set { const CREATION_CARD_IDS = new Set([ "fill-square-with-stone", "thornbush", "killer-ooze", "rosebush", "dust-cloud", - "fill-square-with-slime", "create-pit", "handful-of-tacks", "glue", "safe", "boobytrap", + "fill-square-with-slime", "create-pit", "handful-of-tacks", ]); const SUMMON_CARD_IDS = new Set([ "troll", "skeleton", "wraith", "fire-imp", "democratic-monster", "shadow", @@ -256,6 +256,29 @@ export function eligibleCellsFor(view: GameView, cardId: string): Set | return out; } + if (cardId === "boobytrap") { + // Tokens go anywhere on the board except solid stone — no sight needed; + // a trap you can see coming is a poor trap. + return new Set(cells.filter((k) => view.squareContents[k]?.kind !== "stone")); + } + + if (cardId === "glue" || cardId === "safe") { + // Both seize a square that HOLDS something — a loose object or an + // unclaimed treasure — within the caster's sight. The safe additionally + // needs the square clear of terrain to build around its prize. + const out = new Set(); + for (const k of cells) { + if (cardId === "safe" && view.squareContents[k]) continue; + const hasObject = + (view.groundObjects[k] ?? []).length > 0 || + view.treasures.some((t) => t.position && !t.carriedBy && key(t.position.x, t.position.y) === k); + if (!hasObject) continue; + if (!sighted.has(k)) continue; + out.add(k); + } + return out; + } + if (cardId === "stone-to-water") { return new Set(cells.filter((k) => view.squareContents[k]?.kind === "stone")); } diff --git a/packages/engine/test/expansion-terrain.test.ts b/packages/engine/test/expansion-terrain.test.ts index a6c4ad0..7ad7ba4 100644 --- a/packages/engine/test/expansion-terrain.test.ts +++ b/packages/engine/test/expansion-terrain.test.ts @@ -161,3 +161,35 @@ describe("expansion terrain", () => { // Wave side effects vary by geometry; the melt itself is the pinned behavior. }); }); + +describe("eligibility dimming mirrors the engine", () => { + it("boobytrap tokens go anywhere but solid stone, sight be damned", async () => { + const { viewFor, eligibleCellsFor } = await import("../src/view"); + let { state } = newGame(); + const caster = activePlayer(state).id; + const view = viewFor(state, caster); + const lit = eligibleCellsFor(view, "boobytrap")!; + // Every board square is fair game (the fresh maze holds no solid stone), + // including squares far outside the caster's sight. + expect(lit.size).toBe(Object.keys(view.board.cells).length); + }); + + it("glue lights only sighted squares that hold something", async () => { + const { viewFor, eligibleCellsFor } = await import("../src/view"); + let { state } = newGame(); + const caster = activePlayer(state); + // Drop a dagger at the caster's feet — the one guaranteed-sighted object. + const here = { ...caster.position }; + state.groundObjects[cellKey(here)] = [{ instanceId: "dagger#G", cardId: "dagger" }]; + const lit = eligibleCellsFor(viewFor(state, caster.id), "glue")!; + expect(lit.has(cellKey(here))).toBe(true); + // Empty squares stay dim — glue needs something to glue down. + for (const k of lit) { + const view = viewFor(state, caster.id); + const held = + (view.groundObjects[k] ?? []).length > 0 || + view.treasures.some((t) => t.position && cellKey(t.position) === k); + expect(held).toBe(true); + } + }); +});