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:
co-authored by
Claude Fable 5
parent
f657144127
commit
d6d34887e3
+40
-12
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user