The whole punch list: rev 8, general FAQ, and four promoted UX items

Rules rev 8 closes the last two fidelity threads. A SPEED bonus turn
burns a turn of duration spells on the hastened wizard — recipient-
counted, per the FAQ's most obscure ruling — with self-cast durations
already burning through the normal turn-start sweep (expiry extracted
into expireEffect so both paths share the cleanup). And nothing can
be created on a DIMENSIONAL WARP token, as the card face always said.

The general-topic FAQ sections (Combat, Line of Sight, Monsters,
Treasures, and five more) join the rules tab verbatim under a double-
ruled divider, completing the FAQ's absorption: card rulings on the
cards, general rulings in the rules.

The four promoted UX items: cell-target spells now dim ineligible
squares (creations mirror emptySquareTarget from the viewer's
knowledge, summons want clear sighted squares, teleport BFSes its
four spaces exactly as the engine does, stone-to-water lights only
stone, dispel lights only creations); hotseat games get the full
replay reel, each step seen from its actor's own seat; a "stop
announcing attacks on this device" link in the fanfare modal (undo
lives beside the notification toggle in the lobby); the corner
inspector now serves only the selected casting card, with every
examine-a-card peek unified on the centered treatment; and the dev
socket URL keys on vite's port instead of hijacking every localhost.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-16 15:45:59 -04:00
co-authored by Claude Fable 5
parent f657144127
commit d6d34887e3
10 changed files with 409 additions and 25 deletions
+82
View File
@@ -182,3 +182,85 @@ export function sightedCellsFor(view: GameView): Set<string> {
}
return out;
}
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",
]);
const SUMMON_CARD_IDS = new Set([
"troll", "skeleton", "wraith", "fire-imp", "democratic-monster", "shadow",
]);
/**
* Squares a cell-target card can legally aim at, for the client's dimming
* aid — mirroring the engine's own validation from the viewer's knowledge.
* Null = this card's eligibility is not modeled; light everything.
*/
export function eligibleCellsFor(view: GameView, cardId: string): Set<string> | null {
const me = view.players.find((p) => p.id === view.you);
if (!me) return null;
const cells = Object.keys(view.board.cells);
const key = (x: number, y: number) => `${x},${y}`;
const sighted = sightedCellsFor(view);
if (CREATION_CARD_IDS.has(cardId)) {
// emptySquareTarget: on the board, unoccupied by content, home, wizard,
// treasure, object, or a warp token — and in the caster's sight.
const out = new Set<string>();
for (const k of cells) {
if (view.squareContents[k]) continue;
const [x, y] = k.split(",").map(Number) as [number, number];
if (view.board.homes.some((h) => key(h.x, h.y) === k)) continue;
if (view.players.some((p) => p.alive && key(p.position.x, p.position.y) === k)) continue;
if (view.treasures.some((t) => t.position && key(t.position.x, t.position.y) === k)) continue;
if ((view.groundObjects[k] ?? []).length > 0) continue;
if (view.dimWarps.some((w) => key(w.a.x, w.a.y) === k || key(w.b.x, w.b.y) === k)) continue;
if (!sighted.has(k)) continue;
out.add(k);
void x; void y;
}
return out;
}
if (SUMMON_CARD_IDS.has(cardId)) {
// Summon: any sighted square free of content and creatures.
const out = new Set<string>();
for (const k of cells) {
if (view.squareContents[k]) continue;
if (view.creatures.some((c) => key(c.position.x, c.position.y) === k)) continue;
if (!sighted.has(k)) continue;
out.add(k);
}
return out;
}
if (cardId === "teleport") {
// Up to four spaces, walls and objects ignored; not into solid stone.
// BFS over existing cells, matching the engine's wallIgnoringDistance.
const out = new Set<string>();
const dist = new Map<string, number>([[key(me.position.x, me.position.y), 0]]);
const queue = [me.position];
while (queue.length > 0) {
const cur = queue.shift()!;
const d = dist.get(key(cur.x, cur.y))!;
if (d >= 4) continue;
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]] as const) {
const n = { x: cur.x + dx, y: cur.y + dy };
const nk = key(n.x, n.y);
if (!view.board.cells[nk] || dist.has(nk)) continue;
dist.set(nk, d + 1);
queue.push(n);
if (view.squareContents[nk]?.kind !== "stone") out.add(nk);
}
}
return out;
}
if (cardId === "stone-to-water") {
return new Set(cells.filter((k) => view.squareContents[k]?.kind === "stone"));
}
if (cardId === "dispel-creation") {
return new Set(cells.filter((k) => view.squareContents[k] != null));
}
return null;
}