mirror of
https://github.com/samvallad33/vestige.git
synced 2026-04-30 11:26:22 +02:00
Post-ship audit surfaced 6 FATALs and 4 upgrades. Shipping 5 of the 6 +
all 4 upgrades. FATAL 4 (VRAM hemorrhage from un-pooled label canvases
in createTextSprite) is pre-existing, not from this session, and scoped
separately for a proper texture-pool refactor.
**FATAL 1 — Toast Silent Lobotomy** (stores/toast.ts)
Subscriber tracked events[0] only. When Svelte batched multiple events
in one update tick (swarm firing DreamCompleted + ConnectionDiscovered
within the same millisecond), every event but the newest got silently
dropped. Fixed to walk from index 0 until hitting lastSeen — same
pattern as Graph3D.processEvents. Processes oldest-first to preserve
narrative order.
**FATAL 2 — Premature Birth** (graph/nodes.ts + graph/events.ts)
Orb flight is 138 frames; materialization was 30 frames. Node popped
fully grown ~100 frames before orb arrived — cheap UI glitch instead
of a biological birth. Added `addNode(..., { isBirthRitual: true })`
option that reserves the physics slot but hides mesh/glow/label and
skips the materializing queue. New `igniteNode(id)` flips visibility
and enqueues materialization. events.ts onArrive now calls igniteNode
at the exact docking moment, so the elastic spring-up peaks on impact.
**FATAL 3 — 120Hz ProMotion Time-Bomb** (components/Graph3D.svelte)
All physics + effect counters are frame-based. On a 120Hz display every
ritual ran at 2x speed. Added a `lastTime`-based governor in animate()
that early-returns if dt < 16ms, clamping effective rate to ~60fps.
`- (dt % 16)` carry avoids long-term drift. Zero API changes; tonight's
fast fix until physics is rewritten to use dt.
**FATAL 5 — Bezier GC Panic** (graph/effects.ts birth-orb update)
Flight phase allocated a new Vector3 (control point) and a new
QuadraticBezierCurve3 every frame per orb. With 3 orbs in flight that's
360 objects/sec for the GC to collect. Rewrote as inline algebraic
evaluation — zero allocations per frame, identical curve.
**FATAL 6 — Phantom Shockwave** (graph/events.ts)
A 166ms setTimeout fired the 2nd shockwave. If the user navigated
away during that window the scene was disposed, the timer still
fired, and .add() on a dead scene threw unhandled rejection. Dropped
the setTimeout entirely; both shockwaves fire immediately in onArrive
with different scales/colors for the same layered-crash feel.
**UPGRADE 1 — Sanhedrin Shatter** (graph/effects.ts birth-orb update)
If getTargetPos() returns undefined AFTER gestation (target node was
deleted mid-ritual — Stop hook sniping a hallucination), the orb
turns blood-red, triggers a violent implosion in place, and skips
the arrival cascade. Cognitive immune system made visible.
**UPGRADE 2 — Newton's Cradle** (graph/events.ts onArrive)
On docking the target mesh's scale gets bumped 1.8×, so the elastic
materialization + force-sim springs physically recoil instead of the
orb landing silently. The graph flinches when an idea is born into it.
**UPGRADE 3 — Hover Panic** (stores/toast.ts + InsightToast.svelte)
Paused dwell timer on mouseenter/focus, resume on mouseleave/blur.
Stored remaining ms at pause so resume schedules a correctly-sized
timer. CSS pairs via `animation-play-state: paused` on the progress
bar. A toast the user is reading no longer dismisses mid-sentence.
**UPGRADE 4 — Event Horizon Guard** (components/Graph3D.svelte)
If >MAX_EVENTS (200) events arrive in one tick, lastProcessedEvent
falls off the end of the array and the walk consumes ALL 200 entries
as "fresh" — GPU meltdown from 200 simultaneous births. Detect the
overflow and drop the batch with a console.warn, advancing the
high-water mark so next frame is normal.
Build + test:
- npm run check: 0 errors, 0 warnings
- npm test: 251/251 pass
- npm run build: clean static build
268 lines
8.7 KiB
Svelte
268 lines
8.7 KiB
Svelte
<script lang="ts">
|
|
import { onMount, onDestroy } from 'svelte';
|
|
import type { GraphNode, GraphEdge, VestigeEvent } from '$types';
|
|
import { createScene, resizeScene, disposeScene, type SceneContext } from '$lib/graph/scene';
|
|
import { ForceSimulation } from '$lib/graph/force-sim';
|
|
import { NodeManager, type ColorMode } from '$lib/graph/nodes';
|
|
import { EdgeManager } from '$lib/graph/edges';
|
|
import { ParticleSystem } from '$lib/graph/particles';
|
|
import { EffectManager } from '$lib/graph/effects';
|
|
import { DreamMode } from '$lib/graph/dream-mode';
|
|
import { mapEventToEffects, type GraphMutationContext, type GraphMutation } from '$lib/graph/events';
|
|
import { createNebulaBackground, updateNebula } from '$lib/graph/shaders/nebula.frag';
|
|
import { createPostProcessing, updatePostProcessing, type PostProcessingStack } from '$lib/graph/shaders/post-processing';
|
|
import type * as THREE from 'three';
|
|
|
|
interface Props {
|
|
nodes: GraphNode[];
|
|
edges: GraphEdge[];
|
|
centerId: string;
|
|
events?: VestigeEvent[];
|
|
isDreaming?: boolean;
|
|
/// v2.0.8: colour mode for node spheres. "type" tints by node type
|
|
/// (fact/concept/event/…); "state" tints by FSRS accessibility bucket
|
|
/// (active/dormant/silent/unavailable). Toggled live from the graph page.
|
|
colorMode?: ColorMode;
|
|
onSelect?: (nodeId: string) => void;
|
|
onGraphMutation?: (mutation: GraphMutation) => void;
|
|
}
|
|
|
|
let {
|
|
nodes,
|
|
edges,
|
|
centerId,
|
|
events = [],
|
|
isDreaming = false,
|
|
colorMode = 'type',
|
|
onSelect,
|
|
onGraphMutation,
|
|
}: Props = $props();
|
|
|
|
// Re-tint every live node whenever the color mode flips. The NodeManager's
|
|
// setColorMode is idempotent and mutates materials in place, so this
|
|
// effect runs once per toggle and doesn't rebuild the scene.
|
|
$effect(() => {
|
|
nodeManager?.setColorMode(colorMode);
|
|
});
|
|
|
|
let container: HTMLDivElement;
|
|
let ctx: SceneContext;
|
|
let animationId: number;
|
|
|
|
// Modules
|
|
let nodeManager: NodeManager;
|
|
let edgeManager: EdgeManager;
|
|
let particles: ParticleSystem;
|
|
let effects: EffectManager;
|
|
let forceSim: ForceSimulation;
|
|
let dreamMode: DreamMode;
|
|
let nebulaMaterial: THREE.ShaderMaterial;
|
|
let postStack: PostProcessingStack;
|
|
|
|
// Event tracking — we track the last-processed event by reference identity
|
|
// rather than by count, because the WebSocket store PREPENDS new events
|
|
// at index 0 and CAPS the array at MAX_EVENTS, so a numeric high-water
|
|
// mark would drift out of alignment (and did for ~3 versions — v2.3
|
|
// demo uncovered this while trying to fire multiple MemoryCreated events
|
|
// in sequence).
|
|
let lastProcessedEvent: VestigeEvent | null = null;
|
|
|
|
// Internal tracking: initial nodes + live-added nodes
|
|
let allNodes: GraphNode[] = [];
|
|
|
|
onMount(() => {
|
|
ctx = createScene(container);
|
|
|
|
// Nebula background
|
|
const nebula = createNebulaBackground(ctx.scene);
|
|
nebulaMaterial = nebula.material;
|
|
|
|
// Post-processing (added after bloom)
|
|
postStack = createPostProcessing(ctx.composer);
|
|
|
|
// Modules
|
|
particles = new ParticleSystem(ctx.scene);
|
|
nodeManager = new NodeManager();
|
|
// Apply the initial colour mode before node creation so the first paint
|
|
// already reflects the user's prop choice. Prevents a visible flash from
|
|
// type-colour to state-colour on mount when the page defaults to state.
|
|
nodeManager.colorMode = colorMode;
|
|
edgeManager = new EdgeManager();
|
|
effects = new EffectManager(ctx.scene);
|
|
dreamMode = new DreamMode();
|
|
|
|
// Build graph
|
|
const positions = nodeManager.createNodes(nodes);
|
|
edgeManager.createEdges(edges, positions);
|
|
forceSim = new ForceSimulation(positions);
|
|
|
|
// Track all nodes (initial set)
|
|
allNodes = [...nodes];
|
|
|
|
ctx.scene.add(edgeManager.group);
|
|
ctx.scene.add(nodeManager.group);
|
|
|
|
animate();
|
|
|
|
window.addEventListener('resize', onResize);
|
|
container.addEventListener('pointermove', onPointerMove);
|
|
container.addEventListener('click', onClick);
|
|
});
|
|
|
|
onDestroy(() => {
|
|
cancelAnimationFrame(animationId);
|
|
window.removeEventListener('resize', onResize);
|
|
container?.removeEventListener('pointermove', onPointerMove);
|
|
container?.removeEventListener('click', onClick);
|
|
effects?.dispose();
|
|
particles?.dispose();
|
|
nodeManager?.dispose();
|
|
edgeManager?.dispose();
|
|
if (ctx) disposeScene(ctx);
|
|
});
|
|
|
|
// 120Hz Governor. All physics and effect counters are frame-based
|
|
// (orb.age++, forceSim.tick, materialization frames). On a ProMotion
|
|
// display the browser drives rAF at 120 FPS, which would double-speed
|
|
// every ritual. Clamping to ~60 FPS keeps the visual timing identical
|
|
// across displays without rewriting every counter to use delta time.
|
|
// The `- (dt % 16)` carry avoids long-term drift.
|
|
let govLastTime = 0;
|
|
|
|
function animate() {
|
|
animationId = requestAnimationFrame(animate);
|
|
const now = performance.now();
|
|
if (govLastTime === 0) govLastTime = now;
|
|
const dt = now - govLastTime;
|
|
if (dt < 16) return;
|
|
govLastTime = now - (dt % 16);
|
|
|
|
const time = now * 0.001;
|
|
|
|
// Force simulation
|
|
forceSim.tick(edges);
|
|
|
|
// Update positions
|
|
nodeManager.updatePositions();
|
|
edgeManager.updatePositions(nodeManager.positions);
|
|
|
|
// Animate edge growth/dissolution
|
|
edgeManager.animateEdges(nodeManager.positions);
|
|
|
|
// Animate
|
|
particles.animate(time);
|
|
nodeManager.animate(time, allNodes, ctx.camera);
|
|
|
|
// Dream mode
|
|
dreamMode.setActive(isDreaming);
|
|
dreamMode.update(ctx.scene, ctx.bloomPass, ctx.controls, ctx.lights, time);
|
|
|
|
// Nebula + post-processing
|
|
updateNebula(
|
|
nebulaMaterial,
|
|
time,
|
|
dreamMode.current.nebulaIntensity,
|
|
container.clientWidth,
|
|
container.clientHeight
|
|
);
|
|
updatePostProcessing(postStack, time, dreamMode.current.nebulaIntensity);
|
|
|
|
// Events + effects
|
|
processEvents();
|
|
effects.update(nodeManager.meshMap, ctx.camera, nodeManager.positions);
|
|
|
|
ctx.controls.update();
|
|
ctx.composer.render();
|
|
}
|
|
|
|
function processEvents() {
|
|
if (!events || events.length === 0) return;
|
|
|
|
// Walk the feed from newest (index 0) backward until we hit the last
|
|
// event we already processed. Everything between is fresh. This is
|
|
// robust against both (a) prepend ordering and (b) the MAX_EVENTS cap
|
|
// dropping old entries off the tail.
|
|
const fresh: VestigeEvent[] = [];
|
|
for (const e of events) {
|
|
if (e === lastProcessedEvent) break;
|
|
fresh.push(e);
|
|
}
|
|
if (fresh.length === 0) return;
|
|
|
|
// Event Horizon Guard. If the last-processed reference fell off the
|
|
// end of the capped array (burst of >MAX_EVENTS events in one tick),
|
|
// the walk above consumed the ENTIRE buffer — we'd try to animate
|
|
// 200 simultaneous births and melt the GPU. Detect the overflow and
|
|
// drop this batch on the floor; state is already current via
|
|
// lastProcessedEvent pointing forward.
|
|
if (fresh.length === events.length && events.length >= 200) {
|
|
// eslint-disable-next-line no-console
|
|
console.warn('[vestige] Event horizon overflow: dropping visuals for', fresh.length, 'events');
|
|
lastProcessedEvent = events[0];
|
|
return;
|
|
}
|
|
|
|
lastProcessedEvent = events[0];
|
|
|
|
const mutationCtx: GraphMutationContext = {
|
|
effects,
|
|
nodeManager,
|
|
edgeManager,
|
|
forceSim,
|
|
camera: ctx.camera,
|
|
onMutation: (mutation: GraphMutation) => {
|
|
// Update internal allNodes tracking
|
|
if (mutation.type === 'nodeAdded') {
|
|
allNodes = [...allNodes, mutation.node];
|
|
} else if (mutation.type === 'nodeRemoved') {
|
|
allNodes = allNodes.filter((n) => n.id !== mutation.nodeId);
|
|
}
|
|
// Notify parent
|
|
onGraphMutation?.(mutation);
|
|
},
|
|
};
|
|
|
|
// Process oldest-first so cause precedes effect (e.g. MemoryCreated
|
|
// fires before a ConnectionDiscovered that references the new node).
|
|
// `fresh` is newest-first from the walk above, so iterate reversed.
|
|
for (let i = fresh.length - 1; i >= 0; i--) {
|
|
mapEventToEffects(fresh[i], mutationCtx, allNodes);
|
|
}
|
|
}
|
|
|
|
function onResize() {
|
|
if (!container || !ctx) return;
|
|
resizeScene(ctx, container);
|
|
}
|
|
|
|
function onPointerMove(event: PointerEvent) {
|
|
const rect = container.getBoundingClientRect();
|
|
ctx.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
|
ctx.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
|
|
|
|
ctx.raycaster.setFromCamera(ctx.mouse, ctx.camera);
|
|
const intersects = ctx.raycaster.intersectObjects(nodeManager.getMeshes());
|
|
|
|
if (intersects.length > 0) {
|
|
nodeManager.hoveredNode = intersects[0].object.userData.nodeId;
|
|
container.style.cursor = 'pointer';
|
|
} else {
|
|
nodeManager.hoveredNode = null;
|
|
container.style.cursor = 'grab';
|
|
}
|
|
}
|
|
|
|
function onClick() {
|
|
if (nodeManager.hoveredNode) {
|
|
nodeManager.selectedNode = nodeManager.hoveredNode;
|
|
onSelect?.(nodeManager.hoveredNode);
|
|
|
|
const pos = nodeManager.positions.get(nodeManager.hoveredNode);
|
|
if (pos) {
|
|
ctx.controls.target.lerp(pos.clone(), 0.5);
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div bind:this={container} class="w-full h-full"></div>
|