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
+40 -12
View File
@@ -221,7 +221,8 @@ export interface GameConfig {
* Rev 7 briefly restricted warp sight to aisle corners; the table's
* reading prevailed and all board openings carry sight in every revision.
* DIMENSIONAL WARP's tokens never do ("There is no L.O.S. through the
* warp." — the card face).
* warp." — the card face). Rev 8: nothing can be created on a warp token,
* and a SPEED bonus turn burns a turn of durations on the hastened wizard.
*/
deckRev?: number;
}
@@ -2401,6 +2402,11 @@ function emptySquareTarget(
if (state.players.some((p) => p.alive && cellKey(p.position) === key)) return "someone is standing there";
if (state.treasures.some((t) => t.position && cellKey(t.position) === key)) return "a treasure rests there";
if ((state.groundObjects[key] ?? []).length > 0) return "an object lies there";
// "You can't create an object on a DIMENSIONAL WARP" (rules rev 8).
if ((state.config.deckRev ?? 1) >= 8 &&
state.dimWarps.some((w) => cellKey(w.a) === key || cellKey(w.b) === key)) {
return "the warp shimmers there — nothing can form on it";
}
if (!gameLos(state, caster.position, cell)) return "no line of sight";
return cell;
}
@@ -5266,17 +5272,7 @@ function beginTurnFor(state: GameState, events: GameEvent[], index: number): voi
if (s.casterId === player.id) {
s.remainingTurns--;
if (s.remainingTurns <= 0) {
events.push({ type: "spellExpired", effectId: s.id, cardId: s.cardId, target: s.targetId });
// Edge-bound spells clean up their edge (WALL OF FIRE burns out).
if (s.edge && state.edgeOverrides[s.edge] === "firewall") {
delete state.edgeOverrides[s.edge];
delete state.createdEdges[s.edge];
events.push({ type: "firewallExpired", edge: s.edge });
}
// GLUE dries out: the cell key rides in the same field.
if (s.cardId === "glue" && s.edge) {
delete state.gluedCells[s.edge];
}
expireEffect(state, events, s);
continue;
}
}
@@ -5339,6 +5335,21 @@ function beginTurnFor(state: GameState, events: GameEvent[], index: number): voi
};
}
/** An effect's turns ran out: announce it and clean up what it built. */
function expireEffect(state: GameState, events: GameEvent[], s: SustainedEffect): void {
events.push({ type: "spellExpired", effectId: s.id, cardId: s.cardId, target: s.targetId });
// Edge-bound spells clean up their edge (WALL OF FIRE burns out).
if (s.edge && state.edgeOverrides[s.edge] === "firewall") {
delete state.edgeOverrides[s.edge];
delete state.createdEdges[s.edge];
events.push({ type: "firewallExpired", edge: s.edge });
}
// GLUE dries out: the cell key rides in the same field.
if (s.cardId === "glue" && s.edge) {
delete state.gluedCells[s.edge];
}
}
function doEndTurn(prev: GameState, draw: number): CommandResult {
if (draw < 0 || draw > DRAW_PER_TURN) return err(`you may draw 0-${DRAW_PER_TURN} cards`);
@@ -5408,6 +5419,23 @@ function doEndTurn(prev: GameState, draw: number): CommandResult {
if (p.extraTurns > 0) {
p.extraTurns--;
beginTurnFor(state, events, state.turn.activeIndex);
// "If you have SPEED on you while under the influence of a duration
// spell, it uses up a turn of that duration" — recipient-counted (FAQ;
// rules rev 8). Self-cast durations already burned in beginTurnFor.
if ((state.config.deckRev ?? 1) >= 8) {
const surviving: SustainedEffect[] = [];
for (const s of state.sustained) {
if (s.targetId === p.id && s.casterId !== p.id) {
s.remainingTurns--;
if (s.remainingTurns <= 0) {
expireEffect(state, events, s);
continue;
}
}
surviving.push(s);
}
state.sustained = surviving;
}
events.push({ type: "extraTurnStarted", player: p.id });
events.push({ type: "turnStarted", player: p.id, round: state.turn.round });
return { ok: true, state, events };
+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;
}
+35
View File
@@ -734,3 +734,38 @@ describe("answering counteractions (FAQ rulings)", () => {
expect(cellKey(after.position)).toBe(cellKey(escape));
});
});
describe("rules revision 8", () => {
it("a SPEED bonus turn burns a turn of durations on the hastened wizard", () => {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 8 });
state = toRound2(state);
const me = activePlayer(state);
const other = state.players.find((p) => p.id !== me.id)!;
state.sustained.push({
id: "fx-blind", cardId: "blind", casterId: other.id, targetId: me.id,
remainingTurns: 2, data: {},
});
giveCard(state, me.id, "speed", "SP", 0);
state = must(state, me.id, { type: "cast", instanceId: "speed#SP" });
state = must(state, me.id, { type: "endTurn", draw: 0 });
// The bonus turn began: one duration turn burned, recipient-counted.
expect(activePlayer(state).id).toBe(me.id);
expect(state.sustained.find((s) => s.id === "fx-blind")!.remainingTurns).toBe(1);
state = must(state, me.id, { type: "endTurn", draw: 0 });
expect(activePlayer(state).id).toBe(other.id);
});
it("nothing can be created on a dimensional warp token", () => {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 8 });
state = toRound2(state);
const me = activePlayer(state);
const spot = { x: me.position.x + 1, y: me.position.y };
state.dimWarps.push({ a: spot, b: { x: 0, y: 0 } });
const tb = giveCard(state, me.id, "thornbush", "T", 0);
const r = applyCommand(state, me.id, {
type: "cast", instanceId: tb.instanceId, target: { kind: "cell", cell: spot },
});
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toMatch(/warp shimmers/);
});
});