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:
co-authored by
Claude Fable 5
parent
4528c66057
commit
d2b40ec6f8
+18
-12
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user