From e93bcc15a2415969ba0eae67541e647cc0a407da Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Sat, 29 Aug 2026 19:14:03 -0400 Subject: [PATCH] Credibility pass: session residue swept from the rev-10 run War-story comments become timeless constraints (carrier rule, boobytrap tell, remove-curse prescription); the rev changelog reunites under GameConfig.deckRev; three hand-copied cardless stack literals fold into openCardlessStack; the visionstone one-edge loop is shared via throughOneEdge with the edge-target hairpin exclusion stated; drain() moves to test helpers and replaces four inline copies; the butt-head sweep test loses its dead scaffolding and its expect(refused || true) tautology and now asserts both branches; a process-named suite is renamed for the behavior it pins; dead .bezel-spacer CSS and the fx effect's fossilized cleanup story go. No behavior changes: 304 engine tests pass, 36/36 ledgers replay, web typecheck clean. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF --- packages/engine/src/automaton.ts | 8 +- packages/engine/src/game.ts | 116 +++++++++--------- packages/engine/test/automaton.test.ts | 2 +- packages/engine/test/casting.test.ts | 4 +- packages/engine/test/creatures.test.ts | 14 +-- packages/engine/test/expansion-combat.test.ts | 56 +++------ packages/engine/test/game.test.ts | 10 +- packages/engine/test/helpers.ts | 8 ++ packages/web/src/App.svelte | 12 +- packages/web/src/Board.svelte | 1 - packages/web/src/LiveFirstPerson.svelte | 15 +-- 11 files changed, 107 insertions(+), 139 deletions(-) diff --git a/packages/engine/src/automaton.ts b/packages/engine/src/automaton.ts index 860a242..0dce3fc 100644 --- a/packages/engine/src/automaton.ts +++ b/packages/engine/src/automaton.ts @@ -1070,9 +1070,8 @@ function selfCare(view: GameView, style: AutomatonStyle, tier: TierTraits): Comm } } if (!tier.buffs) return null; // the apprentice's book ends at the stones - // A curse on the clockwork gets scrubbed off. - // The engine wants the whole prescription — patient and ailment both — - // or the cast is refused and the fallback reads as an idle bot. + // A curse on the clockwork gets scrubbed off. The engine wants the + // whole prescription — patient and ailment both — or it refuses the cast. const myCurse = view.sustained.find( (e) => e.targetId === view.you && e.casterId !== view.you && AFFLICTIONS[e.cardId] != null, ); @@ -1594,8 +1593,7 @@ export function automatonCommand( const strikeLive = !view.turn.attackUsed && !view.turn.attackForbidden && !view.turn.actionsEnded && view.yourHand.some((c) => ATTACKS[c.cardId] != null); // A carrier never chases: the delivery in hand outranks the gold in - // someone else's. Chasing while laden once marched a bot across the - // whole board, four steps from a winning drop. + // someone else's. const chasing = thief !== null && strikeLive && !self.carriedTreasureId; // BANK GUARD: enemy gold delivered to my home is my scoreboard, and // anyone may snatch it off the floor. A raider nearer my bank than I diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index b21b32c..4b7e81c 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -232,17 +232,7 @@ export interface CastParams { hold?: boolean; } -/** The revision new games are dealt under; GameConfig.deckRev pins it per game. - * Rev 6: STRENGTH's treasure-tear opens a counteraction window ("this would - * be an attack") instead of resolving instantly. - * Rev 7: DISEASE is a self-cast plague ("You're the carrier!") — the caster - * carries it, sharing a square bites both directions, no counteraction. - * Rev 8: the fire imp's scorch is a SPELL (FAQ) — counteractable, magical — - * and SOULSTONE's floor holds even at 3 life or below. - * Rev 9: the WARD's bite is counteractable ("COUNTERACTIONs ... otherwise - * work as written"), though nothing a counter does touches its caster. - * Rev 10: BUTT-HEAD's ram is MOVEMENT — the charge walks real corridors on - * the turn's legal legs (three plus numbers) and spends them. */ +/** The revision new games are dealt under; GameConfig.deckRev pins it per game. */ export const CURRENT_RULES_REV = 10; export interface GameConfig { @@ -267,6 +257,19 @@ export interface GameConfig { * it; unpassed, it relocks at turn's end as before. * Rev 5: MENTAL FORCE refuses a destination the victim cannot walk to * in three spaces, instead of eating the card silently at resolution. + * Rev 6: STRENGTH's treasure-tear opens a counteraction window ("this + * would be an attack") instead of resolving instantly. + * Rev 7: DISEASE is a self-cast plague ("You're the carrier!") — the + * caster carries it, sharing a square bites both directions, no + * counteraction. + * Rev 8: the fire imp's scorch is a SPELL (FAQ) — counteractable, + * magical — and SOULSTONE's floor holds even at 3 life or below. + * Rev 9: the WARD's bite is counteractable ("COUNTERACTIONs ... + * otherwise work as written"), though nothing a counter does touches + * its caster. + * Rev 10: BUTT-HEAD's ram is MOVEMENT — the charge walks real + * corridors on the turn's legal legs (three plus numbers) and spends + * them. */ deckRev?: number; } @@ -402,21 +405,25 @@ function doorsAjar(state: GameState, viewerId: PlayerId | undefined, board: Asse return edges ? { ...board, edges } : board; } -/** LOS including square-filling blockers. */ /** Sight granted by removing any ONE closed edge — the VISIONSTONE's - * whole power, shared by every path that honors it. */ -function sightThroughOneEdge( - board: AssembledBoard, from: Cell, to: Cell, blockers: Record, -): boolean { + * whole power, shared by every path that honors it. `sees` judges the + * board with the candidate edge gone. */ +function throughOneEdge(board: AssembledBoard, sees: (b: AssembledBoard) => boolean): boolean { for (const key of Object.keys(board.edges)) { if ((board.edges[key] ?? "open") === "open") continue; const edges = { ...board.edges }; delete edges[key]; - if (sightBetween({ ...board, edges }, from, to, blockers)) return true; + if (sees({ ...board, edges })) return true; } return false; } +function sightThroughOneEdge( + board: AssembledBoard, from: Cell, to: Cell, blockers: Record, +): boolean { + return throughOneEdge(board, (b) => sightBetween(b, from, to, blockers)); +} + function losWith(state: GameState, from: Cell, to: Cell, viewerId: PlayerId | undefined, stone: boolean): boolean { const board = doorsAjar(state, viewerId, openedDoors(state, boardView(state))); const blockers = losBlockers(state); @@ -539,15 +546,13 @@ function castSightEdge( side: Side, ): boolean { if (losToEdge(board, caster.position, cell, side)) return true; - if (displays(caster, "visionstone")) { - for (const [key, edge] of Object.entries(board.edges)) { - if (edge === "open") continue; - const edges = { ...board.edges }; - delete edges[key]; - if (losToEdge({ ...board, edges }, caster.position, cell, side)) return true; - } + if (displays(caster, "visionstone") && + throughOneEdge(board, (b) => losToEdge(b, caster.position, cell, side))) { + return true; } if (!cmd.aroundCornerInstanceId) return false; + // The bend pivots in a seen middle SQUARE; the gap-pivot hairpin is + // square-to-square geometry, so an edge target gets no 180-degree bend. for (const key of Object.keys(board.cells)) { const [mx, my] = key.split(",").map(Number) as [number, number]; const mid = { x: mx, y: my }; @@ -2016,7 +2021,7 @@ const CARD_EFFECTS: Record // The FIRST cell in the command is the real trap — but that truth // lives only in realKey. Stored and broadcast in canonical order, // the tokens carry no tell: position must never whisper which one - // bites (the placement order once did, to anyone reading events). + // bites. const laid = [...cells].sort((a, b) => cellKey(a).localeCompare(cellKey(b))); state.boobytraps.push({ casterId: caster.id, cells: laid, realKey: cellKey(cells[0]!) }); events.push({ type: "boobytrapPlaced", caster: caster.id, cells: laid.map((c) => ({ ...c })) }); @@ -3220,21 +3225,34 @@ function openCreatureStack( damage: number, touch?: "wraith" | "claw", kind: "physical" | "spell" = "physical", +): void { + openCardlessStack(state, creature.controllerId, victim.id, { + params: { damage }, + kind, + creatureId: creature.id, + ...(touch ? { creatureTouch: touch } : {}), + }); +} + +/** A counteraction stack with no attack card behind it — a creature's + * blow, the WARD's bite, STRENGTH's tear. Counters ride it as usual. */ +function openCardlessStack( + state: GameState, + attackerId: PlayerId, + defenderId: PlayerId, + opts: Pick & Partial, ): void { state.stack = { - attackerId: creature.controllerId, - defenderId: victim.id, + attackerId, + defenderId, attackCard: null, numberValue: null, amplifyFactor: 1, extendFactor: 1, powerAttackPoints: 0, - params: { damage }, - kind, counters: [], - waitingOn: victim.id, - creatureId: creature.id, - ...(touch ? { creatureTouch: touch } : {}), + waitingOn: defenderId, + ...opts, }; } @@ -4608,20 +4626,9 @@ function doWardChoice(prev: GameState, play: boolean): CommandResult { // nothing a counter does can touch the Ward's caster. Older games // recorded the instant bite and replay it. if ((state.config.deckRev ?? 1) >= 9) { - state.stack = { - attackerId: owner.id, - defenderId: taker.id, - attackCard: null, - numberValue: null, - amplifyFactor: 1, - extendFactor: 1, - powerAttackPoints: 0, - params: { damage: 3 }, - kind: "spell", - counters: [], - waitingOn: taker.id, - trapped: true, - }; + openCardlessStack(state, owner.id, taker.id, { + params: { damage: 3 }, kind: "spell", trapped: true, + }); } else { applyDamage(state, events, taker, 3, "warded treasure", null); checkVictory(state, events); @@ -4746,20 +4753,9 @@ function doTearTreasure(prev: GameState, targetId: PlayerId): CommandResult { // Rev 6: "this would be an attack" — the grab opens a counteraction // window like any punch; the clutch roll waits for the counters. if ((state.config.deckRev ?? 1) >= 6) { - state.stack = { - attackerId: attacker.id, - defenderId: target.id, - attackCard: null, - numberValue: null, - amplifyFactor: 1, - extendFactor: 1, - powerAttackPoints: 0, - params: null, - kind: "physical", - counters: [], - waitingOn: target.id, - tearTreasure: true, - }; + openCardlessStack(state, attacker.id, target.id, { + params: null, kind: "physical", tearTreasure: true, + }); events.push({ type: "treasureTearAttempted", attacker: attacker.id, defender: target.id }); return { ok: true, state, events }; } diff --git a/packages/engine/test/automaton.test.ts b/packages/engine/test/automaton.test.ts index 3a1592c..5f69a31 100644 --- a/packages/engine/test/automaton.test.ts +++ b/packages/engine/test/automaton.test.ts @@ -980,7 +980,7 @@ describe("the denial planner offers only castable blocks", () => { }); }); -describe("game-six lessons from the table", () => { +describe("interception, escape, and hazard sense", () => { function toBot(state: GameState): GameState { while (state.turn.round < 2 || state.players[state.turn.activeIndex]!.id !== "bot") { const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 }); diff --git a/packages/engine/test/casting.test.ts b/packages/engine/test/casting.test.ts index 4624a2f..6639b76 100644 --- a/packages/engine/test/casting.test.ts +++ b/packages/engine/test/casting.test.ts @@ -3,7 +3,7 @@ import { applyCommand, activePlayer, boardView, createGame, type GameState } fro import { cellKey, edgeKey, hasLineOfSight, neighbor, type Cell, type Side, SIDES, stepTarget } from "../src/board"; import type { CardInstance } from "../src/cards"; import { sightedCellsFor, stackSightTrace, viewFor } from "../src/view"; -import { newGame, must, giveCard, toRound2, faceOff } from "./helpers"; +import { newGame, must, drain, giveCard, toRound2, faceOff } from "./helpers"; /** Test surgery: put a specific card into a player's hand (swapping one out). */ /** Advance past round 1 (both players just end their turns). */ @@ -398,7 +398,7 @@ describe("the ward window and chaos shields", () => { expect(state.wardPending).toEqual({ ownerId: owner.id, takerId: thief.id }); // Spring it: the thief bleeds 3 and the ward is spent. state = must(state, owner.id, { type: "wardChoice", play: true }); - while (state.stack) state = must(state, state.stack.waitingOn, { type: "pass" }); + state = drain(state); expect(state.players.find((p) => p.id === thief.id)!.life).toBe(12); expect(state.players.find((p) => p.id === owner.id)!.hand.some((c) => c.cardId === "ward")).toBe(false); }); diff --git a/packages/engine/test/creatures.test.ts b/packages/engine/test/creatures.test.ts index dc6406f..ad49119 100644 --- a/packages/engine/test/creatures.test.ts +++ b/packages/engine/test/creatures.test.ts @@ -3,7 +3,7 @@ import { type CreatureState, applyCommand, activePlayer, boardView, creatureAt, type GameState, type PlayerId, createGame } from "../src/game"; import { cellKey, edgeKey, SIDES, stepTarget, type Cell, type Side } from "../src/board"; import type { CardInstance } from "../src/cards"; -import { newExpansionGame as newGame, must, giveCard, toRound2, emptyNeighborCell, pushSustained } from "./helpers"; +import { newExpansionGame as newGame, must, drain, giveCard, toRound2, emptyNeighborCell, pushSustained } from "./helpers"; /** Summon a creature next to its creator (round 2+, consumes the attack). */ function summon(state: GameState, playerId: PlayerId, kind: string, tag = "S") { @@ -146,14 +146,6 @@ describe("monsters", () => { expect(bitten.hand.length).toBe(6); }); - /** Rev 8: scorches open counteraction windows — wave them through. */ - function drain(state: GameState): GameState { - while (state.stack) { - state = must(state, state.stack.waitingOn, { type: "pass" }); - } - return state; - } - it("the fire imp scorches on sight and dies only to water", () => { let { state } = newGame(); state = toRound2(state); @@ -824,7 +816,7 @@ describe("the imp's fire is a spell (rev 8)", () => { expect(state.stack?.kind).toBe("spell"); giveCard(state, "mark", "full-shield", "FS", 0); state = must(state, "mark", { type: "counteract", instanceId: "full-shield#FS" }); - while (state.stack) state = must(state, state.stack.waitingOn, { type: "pass" }); + state = drain(state); expect(state.players.find((p) => p.id === "mark")!.life).toBe(15); }); @@ -845,7 +837,7 @@ describe("the imp's fire is a spell (rev 8)", () => { state = r.state; if (!state.stack) state = must(state, "imp-owner", { type: "endTurn", draw: 0 }); expect(state.stack?.kind).toBe("spell"); - while (state.stack) state = must(state, state.stack.waitingOn, { type: "pass" }); + state = drain(state); const after = state.players.find((p) => p.id === "mark")!; expect(after.life).toBe(2); expect(after.alive).toBe(true); diff --git a/packages/engine/test/expansion-combat.test.ts b/packages/engine/test/expansion-combat.test.ts index a841537..37bc917 100644 --- a/packages/engine/test/expansion-combat.test.ts +++ b/packages/engine/test/expansion-combat.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { applyCommand, activePlayer, boardView, createGame, gameLos, sustainedOn } from "../src/game"; import { cellKey, edgeKey, stepTarget } from "../src/board"; import type { CardInstance } from "../src/cards"; -import { newExpansionGame as newGame, must, giveCard, toRound2, faceOff, castAt } from "./helpers"; +import { newExpansionGame as newGame, must, drain, giveCard, toRound2, faceOff, castAt } from "./helpers"; describe("expansion combat cards", () => { it("power attack burns life for extra damage", () => { @@ -116,7 +116,7 @@ describe("expansion combat cards", () => { state = must(state, me.id, { type: "pickUpTreasure" }); expect(state.wardPending).toEqual({ ownerId: enemy.id, takerId: me.id }); state = must(state, enemy.id, { type: "wardChoice", play: true }); - while (state.stack) state = must(state, state.stack.waitingOn, { type: "pass" }); + state = drain(state); expect(state.players.find((p) => p.id === me.id)!.life).toBe(12); expect(state.players.find((p) => p.id === enemy.id)!.hand.some((c) => c.cardId === "ward")).toBe(false); }); @@ -684,52 +684,34 @@ describe("go away routs around walls", () => { }); describe("the goat charges on legs, not wings (rev 10)", () => { - it("a ram beyond the turn's legal movement is refused", () => { + it("the charge walks real corridors: in reach it spends legs, beyond them it is refused", () => { let { state } = newGame(); state = toRound2(state); const attacker = activePlayer(state); const defender = state.players.find((p) => p.id !== attacker.id)!; - // Find a cell more than 3 walked steps away and park the victim there. - const view = boardView(state); - let farCell: { x: number; y: number } | null = null; - for (const key of Object.keys(view.cells)) { - const [x, y] = key.split(",").map(Number) as [number, number]; - const r0 = applyCommand(state, attacker.id, { type: "endTurn", draw: 0 }); - void r0; - break; - } - // Use the engine itself as the oracle: try every cell until one refuses. const bh = giveCard(state, attacker.id, "butt-head"); - let refused = false; - for (const key of Object.keys(view.cells)) { + // The engine is the oracle: every other square is either within the + // turn's legal movement or beyond it, and this maze holds both kinds. + let refusals = 0; + let rams = 0; + for (const key of Object.keys(boardView(state).cells)) { + if (key === cellKey(attacker.position)) continue; const [x, y] = key.split(",").map(Number) as [number, number]; defender.position = { x, y }; const r = applyCommand(state, attacker.id, { type: "cast", instanceId: bh.instanceId, target: { kind: "player", playerId: defender.id }, }); - if (!r.ok && /legs, not wings/.test(r.error)) { refused = true; break; } - if (r.ok) { - // In reach: the charge spends movement equal to the walked distance. - let st = r.state; - while (st.stack) { - const rr = applyCommand(st, st.stack.waitingOn, { type: "pass" }); - if (!rr.ok) throw new Error(rr.error); - st = rr.state; - } - const a = st.players.find((p) => p.id === attacker.id)!; - expect(a.position).toEqual(st.players.find((p) => p.id === defender.id)!.position); - expect(st.turn.movementUsed).toBeGreaterThan(0); - break; + if (!r.ok) { + if (/legs, not wings/.test(r.error)) refusals += 1; + continue; } + const st = drain(r.state); + const a = st.players.find((p) => p.id === attacker.id)!; + expect(a.position).toEqual(st.players.find((p) => p.id === defender.id)!.position); + expect(st.turn.movementUsed).toBeGreaterThan(0); + rams += 1; } - // Whichever branch ran, exercise the other with the far corner. - if (!refused) { - defender.position = { x: 0, y: 9 }; - const r = applyCommand(state, attacker.id, { - type: "cast", instanceId: bh.instanceId, target: { kind: "player", playerId: defender.id }, - }); - if (!r.ok) refused = /legs, not wings/.test(r.error); - } - expect(refused || true).toBe(true); + expect(refusals).toBeGreaterThan(0); + expect(rams).toBeGreaterThan(0); }); }); diff --git a/packages/engine/test/game.test.ts b/packages/engine/test/game.test.ts index 82795c2..401e0a7 100644 --- a/packages/engine/test/game.test.ts +++ b/packages/engine/test/game.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { giveCard } from "./helpers"; +import { drain, giveCard } from "./helpers"; import { edgeKey } from "../src/board"; import { applyCommand, @@ -288,11 +288,11 @@ describe("the Ward is played in the moment", () => { expect(state.wardPending).toEqual({ ownerId: "owner", takerId: "thief" }); // Nobody else may act while it hangs. expect(applyCommand(state, "thief", { type: "endTurn", draw: 0 }).ok).toBe(false); - let r = applyCommand(state, "owner", { type: "wardChoice", play: true }); - // Rev 9: the bite rides the stack — wave the counteraction window through. - if (r.ok && r.state.stack) r = applyCommand(r.state, r.state.stack.waitingOn, { type: "pass" }); + const r = applyCommand(state, "owner", { type: "wardChoice", play: true }); if (!r.ok) throw new Error(r.error); - state = r.state; + // The bite rides the stack: a counteraction window opens for the thief. + expect(r.state.stack).toBeTruthy(); + state = drain(r.state); expect(state.wardPending).toBeNull(); expect(state.players.find((p) => p.id === "thief")!.life).toBe(12); expect(state.players.find((p) => p.id === "owner")!.hand.some((c) => c.cardId === "ward")).toBe(false); diff --git a/packages/engine/test/helpers.ts b/packages/engine/test/helpers.ts index 58f8fa9..01dd849 100644 --- a/packages/engine/test/helpers.ts +++ b/packages/engine/test/helpers.ts @@ -29,6 +29,14 @@ export function must(state: GameState, player: PlayerId, command: Command): Game return result.state; } +/** Wave every pending counteraction window through unanswered. */ +export function drain(state: GameState): GameState { + while (state.stack) { + state = must(state, state.stack.waitingOn, { type: "pass" }); + } + return state; +} + export 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 }; diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index db74cd7..1e9c89c 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -423,8 +423,8 @@ } } - /** Every targeted cast goes out through here so attachments never drop — - * the attached NUMBER included, unless the call site already carried it. */ + /** Attachment plumbing for a cast command: the chosen mods and the + * attached NUMBER ride along (an explicit numberInstanceIds wins). */ function withMods[0] & { type: "cast" }>(cmd: T): T { applyMods(cmd); if (attachedNumber && !cmd.numberInstanceIds) { @@ -695,7 +695,6 @@ dispatch(withMods({ type: "cast", instanceId: selectedCard.instanceId, target: { kind: "cell", cell }, - ...(attachedNumber ? { numberInstanceIds: [attachedNumber.instanceId] } : {}), })); clearSelection(); return; @@ -880,7 +879,6 @@ target: { kind: "edge", cell, side }, ...(holdDoor && (selectedCard.cardId === "pick-lock" || selectedCard.cardId === "master-key") ? { params: { hold: true } } : {}), - ...(attachedNumber ? { numberInstanceIds: [attachedNumber.instanceId] } : {}), })); clearSelection(); } @@ -901,7 +899,6 @@ type: "cast", instanceId: selectedCard.instanceId, target: { kind: "creature", creatureId }, ...(selectedCard.cardId === "mega-monster" ? { params: { boost: megaBoost } } : {}), - ...(attachedNumber ? { numberInstanceIds: [attachedNumber.instanceId] } : {}), })); clearSelection(); return; @@ -958,13 +955,12 @@ swapMeetTarget = playerId; return; // next: pick your item, then theirs } - const cmd: Parameters[0] = { + const cmd: Parameters[0] & { type: "cast" } = { type: "cast", instanceId: selectedCard.instanceId, target: { kind: "player", playerId }, }; - if (attachedNumber) cmd.numberInstanceIds = [attachedNumber.instanceId]; - applyMods(cmd); + withMods(cmd); if (selectedCard.cardId === "waterbolt") { cmd.params = { damage: wbDamage, knockback: numberTotal - wbDamage }; } diff --git a/packages/web/src/Board.svelte b/packages/web/src/Board.svelte index f8ab810..09419b3 100644 --- a/packages/web/src/Board.svelte +++ b/packages/web/src/Board.svelte @@ -87,7 +87,6 @@ : { x1: x * CELL, y1: (y + 1) * CELL, x2: (x + 1) * CELL, y2: (y + 1) * CELL }; } - const SECTOR = 5; /** Ghost slots can lie beyond the assembled maze — on any side, including * negative coordinates (the maze renormalizes after the landing). The diff --git a/packages/web/src/LiveFirstPerson.svelte b/packages/web/src/LiveFirstPerson.svelte index 28c0cd9..0138086 100644 --- a/packages/web/src/LiveFirstPerson.svelte +++ b/packages/web/src/LiveFirstPerson.svelte @@ -14,7 +14,7 @@ import { castRay, SIDE_ANGLE, OPPOSITE } from "./fpv/raycast"; import { deepestFacing } from "./fpv/director"; import { aimOfEvents, gatherGlides, hurledIn, shortestArc } from "./fpv/director"; - import { untrack } from "svelte"; + import { onDestroy, untrack } from "svelte"; import type { GameEvent, GameView, Side } from "@wizwar/engine"; let { view, batch, onhide, onstride = null, canStride = false, ontarget = null, onfacing = null, litCells = null, edgeSelect = false, onpickup = null, onCreatureMove = null, onCreatureAttack = null, onbody = null, onWarpStep = null }: { @@ -224,11 +224,10 @@ let actorPos = $state>({}); // --- The fx: each batch plays once, on its own beat. ----------------- - // The effect must track ONLY the batch: the server sends `events` then - // `state` back to back, so anything else in the dependency set (me, the - // view) re-runs this before a zero-delay timer can fire — and an eager - // cleanup would cancel every spell before it left the wand. Timers die - // only with the component. + // Track ONLY the batch: the server sends `events` then `state` back to + // back, so a wider dependency set re-runs this within the same frame, + // before a zero-delay timer can fire. No per-run cleanup for the same + // reason — timers die only with the component. let playedBatch = 0; const fxTimers = new Set>(); $effect(() => { @@ -250,9 +249,8 @@ fxTimers.add(starter); } }); - return undefined; }); - $effect(() => () => fxTimers.forEach(clearTimeout)); + onDestroy(() => fxTimers.forEach(clearTimeout)); // --- Other bodies glide between views. ------------------------------- let prevView: GameView | null = null; @@ -528,7 +526,6 @@ } .bezel-top { border-bottom: 1px solid #2a2620; } .bezel-bottom { border-top: 1px solid #2a2620; } - .bezel-spacer { flex: 1; } .bezel-zone { flex: 1; display: flex; align-items: center; gap: 8px; } .bezel-center { justify-content: center; } .bezel-right { justify-content: flex-end; }