feat(v2.3-terrarium): Memory Birth Ritual + event pipeline fix

v2.3 "Terrarium" headline feature. When a MemoryCreated event arrives, a
glowing orb materialises in the cosmic center (camera-relative z=-40),
gestates for ~800ms growing from a tiny spark into a full orb, then arcs
along a dynamic quadratic Bezier curve to the live position of the real
node, and on arrival hands off to the existing RainbowBurst + Shockwave +
RippleWave cascade. The target position is re-resolved every frame so
the force simulation can move the destination during flight without the
orb losing its mark.

**New primitive — EffectManager.createBirthOrb()** (effects.ts):
  Accepts a camera, a color, a live target-position getter, and an
  arrival callback. Owns a sprite pair (outer halo + inner bright core),
  both depthTest:false with renderOrder 999/1000 so the orb is always
  visible through the starfield and the graph.
  - Gestation phase: easeOutCubic growth + sinusoidal pulse, halo tints
    from neutral to event color as the ritual charges.
  - Flight phase: QuadraticBezierCurve3 with control point at midpoint
    raised on Y by 30 + 15% of orb-to-target distance (shooting-star
    arc). Sampled with easeInOutQuad. Orb shrinks ~35% approaching target.
  - Arrival: fires onArrive callback once, then fades out over 8 frames
    while expanding slightly (energy dispersal).
  - Caller's onArrive triggers the burst cascade at arrivePos (NOT the
    original spawnPos — the force sim may have moved the target during
    the ritual, so we re-read nodeManager.positions on arrival).
  - Dispose path integrated with existing EffectManager.dispose().

**Event pipeline fix — Graph3D.processEvents()**:
  Previously tracked `processedEventCount` assuming APPEND order, but
  websocket.ts PREPENDS new events (index 0) and caps the array at
  MAX_EVENTS. Result: only the first MemoryCreated event after page
  load fired correctly; subsequent ones reprocessed the oldest entry.
  Fixed to walk from index 0 until hitting the last-processed event
  by reference identity — correct regardless of array direction or
  eviction pressure. Events are then processed oldest-first so causes
  precede effects. Found while wiring the v2.3 demo button; would have
  manifested as "first orb only" in production.

**Demo trigger** (Settings -> Birth Ritual Preview):
  Button that calls websocket.injectEvent() with a synthetic
  MemoryCreated event, cycling through node types (fact / concept /
  pattern / decision / person / place) to showcase the type-color
  mapping. Downstream consumers can't distinguish synthetic from real,
  so this drives the full ritual end-to-end. Intended for demo clip
  recording for the Wednesday launch.

**Test coverage:**
  - events.test.ts now tests the v2.3 birth ritual path: spawns 2+
    sprites in the scene immediately, and fires the full arrival
    cascade after driving the effects.update() loop past the ritual
    duration.
  - three-mock.ts extended with Vector3.addVectors, Vector3.applyQuaternion,
    Color.multiplyScalar, Quaternion, QuadraticBezierCurve3, Texture,
    and Object3D.quaternion/renderOrder so production code runs unaltered
    in tests.

Build + typecheck:
- npm run check: 0 errors, 0 warnings across 583 files
- npm test: 251/251 pass (net +0 from v2.2)
- npm run build: clean adapter-static output

The Sanhedrin Shatter (anti-birth ritual for hallucination veto) needs
server-side event plumbing and is deferred. Ship this as the Wednesday
visual mic-drop.
This commit is contained in:
Sam Valladares 2026-04-20 12:47:37 -05:00
parent f01375b815
commit f40aa2e086
8 changed files with 393 additions and 29 deletions

View file

@ -155,7 +155,7 @@ describe('Event-to-Mutation Pipeline', () => {
expect(distToN1).toBeLessThan(20);
});
it('triggers rainbow burst effect', () => {
it('spawns a v2.3 birth orb in the scene', () => {
const childrenBefore = scene.children.length;
mapEventToEffects(
@ -168,16 +168,19 @@ describe('Event-to-Mutation Pipeline', () => {
allNodes
);
// Scene should have new particles (rainbow burst + shockwave + possibly more)
expect(scene.children.length).toBeGreaterThan(childrenBefore);
// Birth orb adds a halo sprite + bright core sprite to the scene
// immediately. The arrival-cascade effects (rainbow burst, shockwaves,
// ripple wave) are deferred to the orb's onArrive callback — covered
// by the "fires arrival cascade after ritual" test below.
expect(scene.children.length).toBeGreaterThanOrEqual(childrenBefore + 2);
});
it('triggers double shockwave (second delayed)', () => {
it('fires the arrival cascade after the birth ritual completes', () => {
vi.useFakeTimers();
mapEventToEffects(
makeEvent('MemoryCreated', {
id: 'double-shock',
id: 'cascade-check',
content: 'test',
node_type: 'fact',
}),
@ -185,13 +188,23 @@ describe('Event-to-Mutation Pipeline', () => {
allNodes
);
const initialChildren = scene.children.length;
const afterSpawn = scene.children.length;
// Advance past the setTimeout
vi.advanceTimersByTime(200);
// Drive the effects update loop past the full ritual duration
// (gestation 48 + flight 90 = 138 frames). Each tick is one frame;
// we run 150 to give onArrive room to fire.
for (let i = 0; i < 150; i++) {
effects.update(nodeManager.meshMap, camera, nodeManager.positions);
}
// Second shockwave should have been added
expect(scene.children.length).toBeGreaterThan(initialChildren);
// Advance the setTimeout that schedules the delayed second shockwave.
vi.advanceTimersByTime(250);
// The arrival cascade should have added a rainbow burst, shockwave,
// ripple wave, and delayed second shockwave to the scene. Even after
// the orb fades out and is removed, the burst particles persist long
// enough that children.length should exceed the post-spawn count.
expect(scene.children.length).toBeGreaterThan(afterSpawn);
vi.useRealTimers();
});

View file

@ -93,6 +93,52 @@ export class Vector3 {
this.z = s;
return this;
}
addVectors(a: Vector3, b: Vector3) {
this.x = a.x + b.x;
this.y = a.y + b.y;
this.z = a.z + b.z;
return this;
}
applyQuaternion(_q: Quaternion) {
// Mock: identity transform. Tests don't care about actual
// camera-relative positioning; production uses real THREE math.
return this;
}
}
export class Quaternion {
x = 0;
y = 0;
z = 0;
w = 1;
}
export class QuadraticBezierCurve3 {
v0: Vector3;
v1: Vector3;
v2: Vector3;
constructor(v0: Vector3, v1: Vector3, v2: Vector3) {
this.v0 = v0;
this.v1 = v1;
this.v2 = v2;
}
getPoint(t: number): Vector3 {
// Standard quadratic Bezier evaluation, faithful enough for tests
// that only care that points land on the curve.
const one = 1 - t;
return new Vector3(
one * one * this.v0.x + 2 * one * t * this.v1.x + t * t * this.v2.x,
one * one * this.v0.y + 2 * one * t * this.v1.y + t * t * this.v2.y,
one * one * this.v0.z + 2 * one * t * this.v1.z + t * t * this.v2.z
);
}
}
export class Texture {
needsUpdate = false;
dispose() {}
}
export class Vector2 {
@ -157,6 +203,13 @@ export class Color {
offsetHSL(_h: number, _s: number, _l: number) {
return this;
}
multiplyScalar(s: number) {
this.r *= s;
this.g *= s;
this.b *= s;
return this;
}
}
export class BufferAttribute {
@ -329,6 +382,8 @@ export class SpriteMaterial extends BaseMaterial {
export class Object3D {
position = new Vector3();
scale = new Vector3(1, 1, 1);
quaternion = new Quaternion();
renderOrder = 0;
userData: Record<string, unknown> = {};
children: Object3D[] = [];
parent: Object3D | null = null;
@ -428,6 +483,9 @@ export function installThreeMock() {
Vector3,
Vector2,
Color,
Quaternion,
QuadraticBezierCurve3,
Texture,
BufferAttribute,
BufferGeometry,
SphereGeometry,