fix(v2.3): 5 FATAL bugs + 4 god-tier upgrades from post-ship audit

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
This commit is contained in:
Sam Valladares 2026-04-20 16:33:25 -05:00
parent f40aa2e086
commit ec614fed85
6 changed files with 242 additions and 52 deletions

View file

@ -70,6 +70,11 @@ interface BirthOrb {
/** Last known target position. If the target disappears mid-flight we keep
* using this snapshot so the orb still lands somewhere sensible. */
lastTargetPos: THREE.Vector3;
/** v2.3: Sanhedrin-Shatter state. Set true when getTargetPos returns
* undefined after gestation the Stop hook deleted the target node
* mid-ritual, so we short-circuit the arrival cascade and implode
* the orb in place as the "cognitive immune system" visual. */
aborted: boolean;
}
export class EffectManager {
@ -334,6 +339,7 @@ export class EffectManager {
arriveFired: false,
onArrive,
lastTargetPos: initialTarget,
aborted: false,
});
}
@ -547,14 +553,29 @@ export class EffectManager {
orb.age++;
const totalFrames = orb.gestationFrames + orb.flightFrames;
// Refresh the live target snapshot — if the node still exists we
// track it, otherwise we fall back to the last known position.
const live = orb.getTargetPos();
if (live) orb.lastTargetPos.copy(live);
const haloMat = orb.sprite.material as THREE.SpriteMaterial;
const coreMat = orb.core.material as THREE.SpriteMaterial;
// Refresh the live target snapshot. If the target getter returns
// undefined DURING flight (not just at spawn), the node was
// aborted mid-ritual — typically a Sanhedrin veto deleting a
// hallucination node while the orb was still in transit. Trigger
// the anti-birth: turn red, implode in place, stop tracking.
const live = orb.getTargetPos();
if (live) {
orb.lastTargetPos.copy(live);
} else if (orb.age > orb.gestationFrames && !orb.aborted) {
orb.aborted = true;
// Fire an implosion where the orb currently is, then splice
// out on the next tick by jumping age to the end of life.
const pos = orb.sprite.position;
haloMat.color.setRGB(1.0, 0.15, 0.2); // blood red
coreMat.color.setRGB(1.0, 0.6, 0.6);
this.createImplosion(pos, new THREE.Color(0xff2533));
orb.arriveFired = true;
orb.age = totalFrames + 1;
}
if (orb.age <= orb.gestationFrames) {
// Gestation phase — pulse brighter + grow from a tiny spark
// into a full orb. Sits still at the cosmic center.
@ -573,27 +594,37 @@ export class EffectManager {
orb.sprite.position.copy(orb.startPos);
orb.core.position.copy(orb.startPos);
} else if (orb.age <= totalFrames) {
// Flight phase — Bezier along a dynamic curve. Arc the
// control point upward for a shooting-star trajectory.
// Flight phase — inline quadratic Bezier eval. Zero-alloc:
// no new Vector3 or QuadraticBezierCurve3 per frame, which
// would flood the GC when several orbs are in flight.
const t = (orb.age - orb.gestationFrames) / orb.flightFrames;
const ease = t < 0.5
? 2 * t * t
: 1 - Math.pow(-2 * t + 2, 2) / 2; // easeInOutQuad
const target = orb.lastTargetPos;
const control = new THREE.Vector3()
.addVectors(orb.startPos, target)
.multiplyScalar(0.5);
control.y += 30 + target.distanceTo(orb.startPos) * 0.15;
const s = orb.startPos;
const tgt = orb.lastTargetPos;
const dx = tgt.x - s.x;
const dy = tgt.y - s.y;
const dz = tgt.z - s.z;
const dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
const cx = (s.x + tgt.x) * 0.5;
const cy = (s.y + tgt.y) * 0.5 + 30 + dist * 0.15;
const cz = (s.z + tgt.z) * 0.5;
const curve = new THREE.QuadraticBezierCurve3(orb.startPos, control, target);
const p = curve.getPoint(ease);
orb.sprite.position.copy(p);
orb.core.position.copy(p);
const oneMinusE = 1 - ease;
const w0 = oneMinusE * oneMinusE;
const w1 = 2 * oneMinusE * ease;
const w2 = ease * ease;
const px = w0 * s.x + w1 * cx + w2 * tgt.x;
const py = w0 * s.y + w1 * cy + w2 * tgt.y;
const pz = w0 * s.z + w1 * cz + w2 * tgt.z;
orb.sprite.position.set(px, py, pz);
orb.core.position.set(px, py, pz);
// Trail effect — shrink + brighten as it approaches target
const flightProgress = ease;
const shrink = 1 - flightProgress * 0.35;
const shrink = 1 - ease * 0.35;
orb.sprite.scale.setScalar(5 * shrink);
orb.core.scale.setScalar(2 * shrink);
haloMat.opacity = 0.95;

View file

@ -125,12 +125,12 @@ export function mapEventToEffects(
// Find spawn position near related nodes
const spawnPos = findSpawnPosition(newNode, allNodes, nodePositions);
// Add to all managers NOW so the force simulation can start
// settling the target position during the birth orb's flight.
// NodeManager materialises from scale 0 via easeOutElastic, so the
// node grows in as the orb descends onto it — visually the orb
// looks like it pulls the memory into existence on contact.
const pos = nodeManager.addNode(newNode, spawnPos);
// Reserve the physics slot but hide the node until the orb docks.
// `isBirthRitual:true` skips the 30-frame materialization push, so
// the mesh/glow/label stay invisible; `igniteNode` below flips
// visibility and kicks off the elastic scale-up AT the exact
// millisecond the orb lands — not 100 frames before.
const pos = nodeManager.addNode(newNode, spawnPos, { isBirthRitual: true });
forceSim.addNode(data.id, pos);
// FIFO eviction
@ -138,9 +138,9 @@ export function mapEventToEffects(
evictOldestLiveNode(ctx, allNodes);
// v2.3 Memory Birth Ritual — cosmic-center orb, Bezier flight,
// arrival burst cascade. The old burst/ripple/shockwave cascade
// now fires on arrival at the docking target, not at spawn, so
// the camera's eye has time to track the orb in.
// arrival burst cascade. The burst/ripple/shockwave cascade
// fires on arrival at the docking target, not at spawn, so the
// eye tracks the orb in and the visuals peak on contact.
const color = new THREE.Color(NODE_TYPE_COLORS[newNode.type] || '#00ffd1');
const hueShifted = color.clone();
hueShifted.offsetHSL(0.15, 0, 0);
@ -150,20 +150,32 @@ export function mapEventToEffects(
color,
// Re-resolve the live target position every frame — the node
// is being pushed around by the force sim during flight.
// Returning undefined here signals "node was aborted" and
// triggers the Sanhedrin-Shatter anti-birth in effects.ts.
() => nodeManager.positions.get(newNode.id),
() => {
// Dock. Fire the arrival cascade at the node's current
// position (not the original spawnPos — it has moved).
// Dock. Ignite the node (flips visibility + starts
// materialization) and fire the arrival cascade at the
// node's CURRENT position — the force sim may have moved
// the target during the ritual, so we re-read positions.
nodeManager.igniteNode(newNode.id);
const arrivePos = nodeManager.positions.get(newNode.id) ?? spawnPos;
// Newton's Cradle — kinetic transfer into the graph.
// Bump the mesh scale on impact so the easeOutElastic
// materialization + force-sim springs physically recoil
// instead of the orb docking silently.
const mesh = nodeManager.meshMap.get(newNode.id);
if (mesh) mesh.scale.multiplyScalar(1.8);
effects.createRainbowBurst(arrivePos, color);
effects.createShockwave(arrivePos, color, camera);
// Fire BOTH shockwaves immediately (different scales /
// colors for layered crash feel). The previous 166ms
// setTimeout could outlive the scene on route change
// and throw an unhandled rejection.
effects.createShockwave(arrivePos, hueShifted, camera);
effects.createRippleWave(arrivePos);
// Second shockwave, hue-shifted, ~166ms delay for a layered
// crash feel rather than a single pop.
setTimeout(() => {
const delayedPos = nodeManager.positions.get(newNode.id) ?? arrivePos;
effects.createShockwave(delayedPos, hueShifted, camera);
}, 166);
}
);

View file

@ -271,7 +271,11 @@ export class NodeManager {
return { mesh, glow: sprite, label: labelSprite, size };
}
addNode(node: GraphNode, initialPosition?: THREE.Vector3): THREE.Vector3 {
addNode(
node: GraphNode,
initialPosition?: THREE.Vector3,
options: { isBirthRitual?: boolean } = {}
): THREE.Vector3 {
const pos =
initialPosition?.clone() ??
new THREE.Vector3(
@ -289,17 +293,62 @@ export class NodeManager {
(glow.material as THREE.SpriteMaterial).opacity = 0;
(label.material as THREE.SpriteMaterial).opacity = 0;
if (options.isBirthRitual) {
// v2.3 Birth Ritual: reserve the physics slot but don't show
// anything until the orb docks. Hiding via .visible keeps the
// force simulation + positions map fully active, so getTargetPos()
// can still resolve the live destination for the orb. `igniteNode`
// below flips visibility and kicks off the materialization anim.
mesh.visible = false;
glow.visible = false;
label.visible = false;
mesh.userData.birthRitualPending = {
totalFrames: 30,
targetScale: 0.5 + node.retention * 2,
};
} else {
this.materializingNodes.push({
id: node.id,
frame: 0,
totalFrames: 30,
mesh,
glow,
label,
targetScale: 0.5 + node.retention * 2,
});
}
return pos;
}
/**
* v2.3 Birth Ritual docking. Flip visibility and hand the node over to
* the materialization queue so it springs up via easeOutElastic at the
* exact moment the orb hits. No-op if the node wasn't created with
* `isBirthRitual:true` or was already ignited.
*/
igniteNode(id: string) {
const mesh = this.meshMap.get(id);
const glow = this.glowMap.get(id);
const label = this.labelSprites.get(id);
if (!mesh || !glow || !label) return;
const pending = mesh.userData.birthRitualPending as
| { totalFrames: number; targetScale: number }
| undefined;
if (!pending) return;
mesh.visible = true;
glow.visible = true;
label.visible = true;
delete mesh.userData.birthRitualPending;
this.materializingNodes.push({
id: node.id,
id,
frame: 0,
totalFrames: 30,
totalFrames: pending.totalFrames,
mesh,
glow,
label,
targetScale: 0.5 + node.retention * 2,
targetScale: pending.targetScale,
});
return pos;
}
removeNode(id: string) {