Credibility pass: the seams get sanded

Scoped to everything since the last pass (3308850). Three blind
reviews, every finding verified before touching anything.

Confirmed and fixed: two identical comment-splitting insertions left
doc comments orphaned from their fields (net and local alike); a
51-line CSS fossil of the pre-extraction inline effects survived in
Board.svelte; the rev-13 miss-roll test asserted tautologies while
its comment claimed a check the code never made — it now proves the
die was consumed, and the skeleton is no longer returned as trollId;
the sprite registry's `as never` silently disabled the completeness
its annotation advertised (now a mapped type, one cast at the
dispatch seam); a dead ternary guarded a union that doesn't exist;
Bolt carried a duplicate .fork rule from a color iteration; fxTtl
contradicted three sprites' real animation lengths; the permanence
sentinel was reinvented as a magic 9000 (the engine now exports
isPermanentDuration); CELL was declared thrice (fx.ts now imports
it); App and Replay ran two divergent fx schedulers (one scheduleFx
now, cancellable — stale flourishes can no longer fire after leaving
a game); TokenArt retried missing files forever; the anti-anti
escape guards merge with the gate asymmetry explained; wall-of-fire's
rev-12 carve-out is marked; overLimit ignored a displayed BRAINSTONE
(bots over-discarded by two); botRemark's header mis-stated its own
branches; deliverGold fired on any drop, not a home-base delivery;
escape and win banter never fired from the steps that carry them.

Rejected: "as a human would" (house voice); FxGallery's dev-harness
framing (trimmed one plea, kept the facts).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-17 12:12:03 -04:00
co-authored by Claude Fable 5
parent 4528c66057
commit d2b40ec6f8
16 changed files with 116 additions and 130 deletions
+18 -12
View File
@@ -7,10 +7,10 @@
import Help from "./Help.svelte";
import Replay from "./Replay.svelte";
import FxGallery from "./FxGallery.svelte";
import { fxForEvents, fxTtl, type BoardFx } from "./fx";
import { scheduleFx, type BoardFx } from "./fx";
import { CREATURE_ART, objectArt, TERRAIN_ART, tokenArt } from "./art";
import { local } from "./local.svelte";
import { allCardDefs, cardDef, isNumberCard, SIDES, stepTarget, cellKey, isMovableObject, sightedCellsFor, type GameView, eligibleCellsFor } from "@wizwar/engine";
import { allCardDefs, cardDef, isNumberCard, isPermanentDuration, SIDES, stepTarget, cellKey, isMovableObject, sightedCellsFor, type GameView, eligibleCellsFor } from "@wizwar/engine";
import type { CardInstance, Side } from "@wizwar/engine";
net.connect();
@@ -37,22 +37,28 @@
localStorage.setItem("wizwar-no-fanfare", announce ? "0" : "1");
}
/** Which pending attack the player has already acknowledged (modal dismissed). */
const fxWorkshop = new URLSearchParams(location.search).has("fx");
let attackNoticeSeen = $state<string | null>(null);
/** Live spell flourishes on the board (cosmetic, self-expiring). */
let boardFx = $state<BoardFx[]>([]);
function playFx(events: Parameters<typeof fxForEvents>[0]) {
let fxCancels: (() => void)[] = [];
function playFx(events: Parameters<typeof scheduleFx>[0]) {
if (!view) return;
for (const { fx, delay } of fxForEvents(events, view)) {
setTimeout(() => {
boardFx = [...boardFx, fx];
setTimeout(() => (boardFx = boardFx.filter((f) => f.id !== fx.id)), fxTtl(fx.kind));
}, delay);
}
fxCancels.push(scheduleFx(
events, view,
(fx) => (boardFx = [...boardFx, fx]),
(id) => (boardFx = boardFx.filter((f) => f.id !== id)),
));
}
$effect(() => {
net.onFx = playFx;
local.onFx = playFx;
return () => { net.onFx = null; local.onFx = null; };
return () => {
net.onFx = null;
local.onFx = null;
fxCancels.forEach((c) => c());
fxCancels = [];
};
});
const openingRolls = $derived(net.openingRolls ?? local.openingRolls);
function dismissRolls() {
@@ -906,7 +912,7 @@
}
</script>
{#if new URLSearchParams(location.search).has("fx")}
{#if fxWorkshop}
<FxGallery />
{:else}
<div class="table">
@@ -1413,7 +1419,7 @@
peekCard = { instanceId: `peek-${e.id}`, cardId: e.cardId };
peekCreatureId = null;
}}>
✦ {spellName(e.cardId)}{e.remainingTurns < 9000 ? ` · ${e.remainingTurns}` : ""}
✦ {spellName(e.cardId)}{isPermanentDuration(e.remainingTurns) ? "" : ` · ${e.remainingTurns}`}
</button>
{/each}
</div>
+6 -55
View File
@@ -1,6 +1,8 @@
<script lang="ts">
import type { Component } from "svelte";
import type { GameView } from "@wizwar/engine";
import type { Side } from "@wizwar/engine";
import type { BoardFx } from "./fx";
import { colorIndexOf as sharedColorIndex, wizardColor } from "./colors";
import { CREATURE_ART, objectArt, TERRAIN_ART, tokenArt } from "./art";
import TokenArt from "./TokenArt.svelte";
@@ -51,11 +53,10 @@
ghostSlots?: { x: number; y: number }[] | null;
onGhostClick?: (origin: { x: number; y: number }) => void;
/** Short-lived spell flourishes; purely cosmetic. */
effects?: import("./fx").BoardFx[] | null;
effects?: BoardFx[] | null;
} = $props();
const SECTOR = 5;
/** Ghost slots can lie beyond the assembled maze — on any side, including
* negative coordinates (the maze renormalizes after the landing). The
@@ -587,8 +588,9 @@
<!-- spell flourishes: one sprite component per effect (src/fx-sprites/) -->
<g class="fx-layer" aria-hidden="true">
{#each effects ?? [] as fx (fx.id)}
{@const Sprite = FX_SPRITES[fx.kind]}
<Sprite fx={fx as never} />
<!-- One cast at the dispatch seam; the registry stays exhaustive. -->
{@const Sprite = FX_SPRITES[fx.kind] as Component<{ fx: BoardFx }>}
<Sprite {fx} />
{/each}
</g>
</svg>
@@ -749,57 +751,6 @@
stroke-width: 1.6;
pointer-events: none;
}
.fx-dust { fill: rgba(160, 150, 130, 0.75); animation: fx-drift 0.7s ease-out forwards; }
.fx-dust.late { animation-delay: 0.09s; }
.fx-dust.later { animation-delay: 0.17s; }
@keyframes fx-fade { 70% { opacity: 1; } 100% { opacity: 0; } }
@keyframes fx-flicker {
0% { opacity: 0; } 15% { opacity: 1; } 40% { opacity: 0.3; }
60% { opacity: 1; } 100% { opacity: 0; }
}
@keyframes fx-ring {
0% { opacity: 0.95; transform: scale(1); }
100% { opacity: 0; transform: scale(5); }
}
@keyframes fx-ring-small {
0% { opacity: 0.9; transform: scale(1); }
100% { opacity: 0; transform: scale(2.6); }
}
@keyframes fx-shield-pulse {
0% { opacity: 0; transform: scale(0.6); }
30% { opacity: 1; transform: scale(1.1); }
60% { transform: scale(0.95); }
100% { opacity: 0; transform: scale(1.15); }
}
@keyframes fx-drop {
0% { opacity: 0.9; transform: translateY(0); }
100% { opacity: 0; transform: translateY(-14px); }
}
@keyframes fx-drift {
0% { opacity: 0.8; transform: translateY(0) scale(1); }
100% { opacity: 0; transform: translateY(-10px) scale(1.8); }
}
@keyframes fx-pow-hit {
0% { opacity: 0; transform: scale(0.3) rotate(-15deg); }
25% { opacity: 1; transform: scale(1.25) rotate(5deg); }
55% { transform: scale(1) rotate(0deg); }
100% { opacity: 0; transform: scale(1.05); }
}
@keyframes fx-claw-rake {
0% { opacity: 0; transform: translateY(-6px); }
20% { opacity: 1; }
100% { opacity: 0; transform: translateY(6px); }
}
@keyframes fx-swallow {
0% { opacity: 0.95; transform: scale(1) rotate(0deg); }
100% { opacity: 0; transform: scale(0.05) rotate(50deg); }
}
@keyframes fx-spin {
0% { opacity: 0; transform: rotate(0deg) scale(0.5); }
30% { opacity: 1; }
100% { opacity: 0; transform: rotate(90deg) scale(1.1); }
}
.warp-dest {
fill: none;
stroke: #2e7d32;
+6 -5
View File
@@ -1,9 +1,10 @@
<script lang="ts">
// The flourish workshop: every effect sprite on a loop, labeled, against a
// mini-maze. Open with /?fx while `npm run dev` runs — edits to any sprite
// file hot-reload here mid-animation. Not linked from anywhere; harmless.
// file hot-reload here mid-animation.
import type { Component } from "svelte";
import { FX_SPRITES } from "./fx-sprites";
import type { BoardFx } from "./fx";
import type { BoardFx, FxShape } from "./fx";
let tick = $state(0);
$effect(() => {
@@ -42,7 +43,7 @@
{ kind: "edge-dust", cell: mid, side: "S" },
{ kind: "sector-spin", origin: { x: 0, y: 0 }, clockwise: true },
{ kind: "sector-slide", from: { x: 0, y: 0 }, to: { x: 1, y: 0 } },
] as unknown as BoardFx[]).map((f, i) => ({ ...f, id: i + 1 }));
] as FxShape[]).map((f, i): BoardFx => ({ ...f, id: i + 1 }));
const isSector = (k: string) => k.startsWith("sector-");
</script>
@@ -55,7 +56,7 @@
</p>
<div class="grid">
{#each SAMPLES as fx (fx.kind)}
{@const Sprite = FX_SPRITES[fx.kind]}
{@const Sprite = FX_SPRITES[fx.kind] as Component<{ fx: BoardFx }>}
<figure>
<svg viewBox={isSector(fx.kind) ? "-4 -4 296 296" : "-4 -4 152 152"}>
{#each Array.from({ length: isSector(fx.kind) ? 6 : 3 }, (_, cy) => cy) as cy (cy)}
@@ -64,7 +65,7 @@
{/each}
{/each}
{#key tick}
<g class="fx-layer"><Sprite fx={fx as never} /></g>
<g class="fx-layer"><Sprite {fx} /></g>
{/key}
</svg>
<figcaption>{fx.kind}</figcaption>
+7 -9
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import Board from "./Board.svelte";
import { humanize } from "./net.svelte";
import { fxForEvents, fxTtl, type BoardFx } from "./fx";
import { scheduleFx, type BoardFx } from "./fx";
import type { GameEvent, GameView } from "@wizwar/engine";
let {
@@ -26,14 +26,12 @@
$effect(() => {
const step = steps[Math.min(idx, steps.length - 1)];
if (!step) return;
const timers: ReturnType<typeof setTimeout>[] = [];
for (const { fx, delay } of fxForEvents(step.events, step.view)) {
timers.push(setTimeout(() => {
boardFx = [...boardFx, fx];
timers.push(setTimeout(() => (boardFx = boardFx.filter((f) => f.id !== fx.id)), fxTtl(fx.kind)));
}, delay));
}
return () => { timers.forEach(clearTimeout); boardFx = []; };
const cancel = scheduleFx(
step.events, step.view,
(fx) => (boardFx = [...boardFx, fx]),
(id) => (boardFx = boardFx.filter((f) => f.id !== id)),
);
return () => { cancel(); boardFx = []; };
});
$effect(() => {
+5 -3
View File
@@ -7,7 +7,7 @@
// are cached per URL, with their ids namespaced so 45 tokens' worth of
// filter/pattern defs cannot collide in one document.
type Entry = { vb: string; inner: string };
const cache = new SvelteMap<string, Entry | "pending">();
const cache = new SvelteMap<string, Entry | "pending" | "failed">();
function load(href: string): void {
if (cache.has(href)) return;
@@ -24,7 +24,9 @@
const vb = m ? (/viewBox="([^"]*)"/.exec(m[1]!)?.[1] ?? "0 0 144 150") : "0 0 144 150";
cache.set(href, { vb, inner: m?.[2] ?? "" });
})
.catch(() => cache.delete(href));
// A failed fetch stays failed: deleting would retrigger the effect
// and hammer a missing file forever.
.catch(() => cache.set(href, "failed"));
}
</script>
@@ -49,7 +51,7 @@
</script>
{#if isSvg}
{#if entry && entry !== "pending"}
{#if entry && entry !== "pending" && entry !== "failed"}
<svg {x} {y} {width} {height} viewBox={entry.vb}
preserveAspectRatio="xMidYMid slice" class={cls}>
{#if title}<title>{title}</title>{/if}
+1 -2
View File
@@ -51,7 +51,7 @@
}
.bolt .fork {
fill: none;
stroke: #fff6b0;
stroke: #f5d54a;
stroke-width: 1.6;
stroke-linejoin: round;
}
@@ -61,7 +61,6 @@
stroke-width: 2;
stroke-linejoin: round;
}
.bolt .fork { stroke: #f5d54a; }
.bolt { animation: bolt-flicker 0.5s steps(2, jump-none) forwards; }
@keyframes bolt-flicker {
0% { opacity: 0; } 15% { opacity: 1; } 40% { opacity: 0.3; }
+2 -2
View File
@@ -20,9 +20,9 @@
</script>
<g style={`--ax: ${away.x}px; --ay: ${away.y}px; --bx: ${-away.x}px; --by: ${away.y}px`}>
<circle cx={c.x - 6} cy={c.y} r="7" fill={`url(#dust-soft-${uid})`} class="puff there" />
<circle cx={c.x - 6} cy={c.y} r="7" fill={`url(#dust-soft-${uid})`} class="puff" />
<circle cx={c.x + 5} cy={c.y - 2} r="5" fill={`url(#dust-soft-${uid})`} class="puff back late" />
<circle cx={c.x} cy={c.y + 3} r="6" fill={`url(#dust-soft-${uid})`} class="puff there later" />
<circle cx={c.x} cy={c.y + 3} r="6" fill={`url(#dust-soft-${uid})`} class="puff later" />
<g class="grit">
<circle cx={c.x - 4} cy={c.y - 5} r="1.3" />
<circle cx={c.x + 6} cy={c.y + 2} r="1.6" />
+3 -3
View File
@@ -2,7 +2,7 @@
// file; to add one, create the component, extend BoardFx in ../fx.ts, and
// register it here.
import type { Component } from "svelte";
import type { BoardFx } from "../fx";
import type { BoardFx, FxOf } from "../fx";
import Absorb from "./Absorb.svelte";
import Bolt from "./Bolt.svelte";
import Burst from "./Burst.svelte";
@@ -29,7 +29,7 @@ import ThornSnap from "./ThornSnap.svelte";
import Waterbolt from "./Waterbolt.svelte";
import Whiff from "./Whiff.svelte";
export const FX_SPRITES: Record<BoardFx["kind"], Component<{ fx: never }>> = {
export const FX_SPRITES: { [K in BoardFx["kind"]]: Component<{ fx: FxOf<K> }> } = {
fireball: Fireball,
waterbolt: Waterbolt,
bolt: Bolt,
@@ -58,4 +58,4 @@ export const FX_SPRITES: Record<BoardFx["kind"], Component<{ fx: never }>> = {
"edge-dust": DustPuff,
"sector-spin": SectorGrind,
"sector-slide": SectorGrind,
} as never;
};
+24 -3
View File
@@ -9,12 +9,12 @@ type Cell = { x: number; y: number };
type Pt = { x: number; y: number };
type Side = "N" | "E" | "S" | "W";
const CELL = 48;
import { CELL } from "./fx-sprites/geom";
const cellMid = (c: Cell): Pt => ({ x: c.x * CELL + CELL / 2, y: c.y * CELL + CELL / 2 });
/** Where a wizard token's center sits in a cell (mirrors Board.svelte). */
const wizMid = (c: Cell): Pt => ({ x: c.x * CELL + CELL / 2, y: c.y * CELL + CELL * 0.36 });
type FxShape =
export type FxShape =
| { kind: "fireball" | "bolt" | "waterbolt" | "streak"; a: Pt; b: Pt }
| { kind: "burst" | "splash" | "shimmer" | "shield" | "sparkle" | "whiff" | "hit"
| "pow" | "claw" | "absorb" | "portal-cell" | "soul" | "fireworks" | "chaos-swirl"
@@ -134,7 +134,7 @@ export function fxForEvents(
break;
case "creatureAttacked": {
// The target may be a wizard or a fellow creature.
const at = posOf(typeof e.target === "string" ? e.target : null) ??
const at = posOf(e.target) ??
(() => {
const c = view.creatures.find((c) => c.id === e.target);
return c ? { ...c.position } : null;
@@ -306,3 +306,24 @@ export function fxForEvents(
}
return out;
}
/** Schedule a batch's effects into `add`, expiring each after its run.
* Returns a cancel that stops pending starts and sweeps what began. */
export function scheduleFx(
events: GameEvent[], view: GameView,
add: (fx: BoardFx) => void, remove: (id: number) => void,
): () => void {
const timers: ReturnType<typeof setTimeout>[] = [];
const started: number[] = [];
for (const { fx, delay } of fxForEvents(events, view)) {
timers.push(setTimeout(() => {
started.push(fx.id);
add(fx);
timers.push(setTimeout(() => remove(fx.id), fxTtl(fx.kind)));
}, delay));
}
return () => {
timers.forEach(clearTimeout);
started.forEach(remove);
};
}
+1 -1
View File
@@ -48,11 +48,11 @@ class LocalGame {
/** Set while the device should be handed to the named player. */
handoffTo = $state<PlayerId | null>(null);
log = $state<string[]>([]);
/** The finished game as a reel, each step from its actor's own seat. */
/** The opening roll-off, shown once as the boards flip. */
openingRolls = $state<{ rolls: Record<string, number[]>; first: string; players: string[] } | null>(null);
/** Board flourishes: the app hooks in to animate command results. */
onFx: ((events: GameEvent[]) => void) | null = null;
/** The finished game as a reel, each step from its actor's own seat. */
replaySteps = $state<{ seq: number; actor: PlayerId; events: GameEvent[]; view: GameView }[] | null>(null);
view = $derived(
this.gameState && this.viewerId ? viewFor(this.gameState, this.viewerId) : null,
+3 -3
View File
@@ -250,11 +250,11 @@ class Net {
transferCode = $state<{ code: string; expiresAt: number } | null>(null);
/** Moves you haven't watched yet in the current room. */
missedMoves = $state(0);
/** A catch-up reel delivered by the server. */
/** The opening roll-off, shown once as the boards flip. */
openingRolls = $state<{ rolls: Record<string, number[]>; first: string; players: string[] } | null>(null);
/** Board flourishes: the app hooks in to animate live event batches. */
onFx: ((events: GameEvent[]) => void) | null = null;
/** A catch-up reel delivered by the server. */
catchUp = $state<{ seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[] | null>(null);
private seen: Record<string, number> = loadSeen();
/** Room whose live stream this connection has already shown once: states
@@ -326,8 +326,8 @@ class Net {
// Only the FIRST state after arriving carries a gap worth
// announcing. Later states were watched live: a caught-up
// watcher stays caught up, and an announced gap stays FROZEN
// (not grown, not wiped) until watched or skipped. A hidden
// tab accumulates its gap honestly.
// until watched or skipped. A hidden tab accumulates its gap
// honestly.
if (this.watching === this.roomId && document.visibilityState === "visible") {
if (this.missedMoves === 0) this.markSeen();
} else {