mirror of
https://github.com/samvallad33/vestige.git
synced 2026-07-10 22:32:11 +02:00
v2.0.8 Pulse — Reasoning Theater + Pulse Toast + Terrarium + Intel Mac (#44)
* docs: anti-hedging banner + master state-and-plan + UI roadmap
Project-level CLAUDE.md line 1 is now the anti-hedging banner referencing
the global rule at /Users/entity002/CLAUDE.md. When Sam commits to an
execution path (content cadence, shipping velocity, outreach, launch),
forecasts compute as if the commitment is already met — no discounting by
execution probability.
VESTIGE_STATE_AND_PLAN.md (1135 lines, 14 parts): single-page reference
any AI agent can read to plan the next phase without re-reading the repo.
Covers current state of every crate, the 24 MCP tools, the 19 WebSocket
event types, the 8 IDE integrations, and v2.1 through v3.0 roadmap.
UI_ROADMAP_v2.1_v2.2.md: compiled UI-gap analysis from 4 parallel research
agents (April 19). 10 critical UI gaps + 2026 bleeding-edge patterns +
unclaimed territory (menu bar widget, voice-first, AR/VR, time-scrubber).
* feat(v2.2-pulse): InsightToast + multi-process STORAGE docs
Two independent ship items landing together on the v2.2 branch ahead of
the Tuesday launch — a new UI surface that makes Vestige's cognitive
events visible in real time, and honest documentation of the multi-process
safety story that underpins the Stigmergic Swarm narrative.
**InsightToast** (apps/dashboard/src/lib/components/InsightToast.svelte,
apps/dashboard/src/lib/stores/toast.ts):
The dashboard already had a working WebSocket event stream on
ws://localhost:3927/ws that broadcast every cognitive event (dream
completions, consolidation sweeps, memory promotions/demotions, active-
forgetting suppression and Rac1 cascades, bridge discoveries). None of
that was surfaced to a user looking at anything other than the raw feed
view. InsightToast subscribes to the existing eventFeed derived store,
filters the spammy lifecycle events (Heartbeat, SearchPerformed,
RetentionDecayed, ActivationSpread, ImportanceScored, MemoryCreated),
and translates the narrative events into ephemeral toasts with a
bioluminescent colored accent matching EVENT_TYPE_COLORS.
Design notes:
- Rate-limited ConnectionDiscovered at 1.5s intervals (dreams emit many).
- Max 4 visible toasts, auto-dismiss at 4.5-7s depending on event weight.
- Click or Enter/Space to dismiss early.
- Bottom-right on desktop, top-banner on mobile.
- Reduced-motion honored via prefers-reduced-motion.
- Zero new websocket subscriptions — everything piggybacks on the
existing derived store.
Also added a "Preview Pulse" button to Settings -> Cognitive Operations
that fires a synthetic sequence of four toasts (DreamCompleted,
ConnectionDiscovered, MemorySuppressed, ConsolidationCompleted) so
the animation is demoable without waiting for real cognitive activity.
**Multi-Process Safety section in docs/STORAGE.md**:
Grounds the Stigmergic Swarm story with concrete tables of what the
current WAL + 5s busy_timeout configuration actually supports vs what
remains experimental. Key honest points:
- Shared --data-dir + ONE vestige-mcp + N clients is the shipping
pattern for multi-agent coordination.
- Two vestige-mcp processes writing the same file is experimental —
documented with the lsof + pkill recovery path.
- Roadmap lists the three items that would promote it to "supported":
advisory file lock, retry-with-jitter on SQLITE_BUSY, and a
concurrent-writer load test.
Build + typecheck:
- npm run check: 0 errors, 0 warnings across 583 files
- npm run build: clean static build, adapter-static succeeds
* 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.
* 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
* test(v2.3): full e2e + integration coverage for Pulse + Birth Ritual
Post-ship verification pass — five parallel write-agents produced 229 new
tests across vitest units, vitest integration, and Playwright browser e2e.
Net suite: 361 vitest pass (up from 251, +110) and 9/9 Playwright pass on
back-to-back runs.
**toast.test.ts (NEW, 661 lines, 42 tests)**
Silent-lobotomy batch walk proven (multi-event tick processes ALL, not
just newest, oldest-first ordering preserved). Hover-panic pause/resume
with remaining-ms math. All 9 event type translations asserted, all 11
noise types asserted silent. ConnectionDiscovered 1500ms throttle.
MAX_VISIBLE=4 eviction. clear() tears down all timers. fireDemoSequence
staggers 4 toasts at 800ms intervals. vi.useFakeTimers + vi.mock of
eventFeed; vi.resetModules in beforeEach for module-singleton isolation.
**websocket.test.ts (NEW, 247 lines, 30 tests)**
injectEvent adds to front, respects MAX_EVENTS=200 with FIFO eviction,
triggers eventFeed emissions. All 6 derived stores (isConnected,
heartbeat, memoryCount, avgRetention, suppressedCount, uptimeSeconds)
verified — defaults, post-heartbeat values, clearEvents preserves
lastHeartbeat. 13 formatUptime boundary cases (0/59/60/3599/3600/
86399/86400 seconds + negative / NaN / ±Infinity).
**effects.test.ts (EXTENDED, +501 lines, +21 tests, 51 total)**
createBirthOrb full lifecycle — sprite count (halo + core), cosmic
center via camera.quaternion, gestation phase (position lock, opacity
rise, scale easing, color tint), flight Bezier arc above linear
midpoint at t=0.5, dynamic mid-flight target redirect. onArrive fires
exactly once at frame 139. Post-arrival fade + disposal cleans scene
children. Sanhedrin Shatter: target goes undefined mid-flight →
onArrive NEVER called, implosion spawned, halo blood-red, eventual
cleanup. dispose() cleans active orbs. Multiple simultaneous orbs.
Custom gestation/flight frame opts honored. Zero-alloc invariant
smoke test (6 orbs × 150 frames, no leaks).
**nodes.test.ts (EXTENDED, +197 lines, +10 tests, 42 total)**
addNode({isBirthRitual:true}) hides mesh/glow/label immediately,
stamps birthRitualPending sentinel with correct totalFrames +
targetScale, does NOT enqueue materialization. igniteNode flips
visibility + enqueues materialization. Idempotent — second call
no-op. Non-ritual nodes unaffected. Unknown id is safe no-op.
Position stored in positions map while invisible (force sim still
sees it). removeNode + late igniteNode is safe.
**events.test.ts (EXTENDED, +268 lines, +7 tests, 55 total)**
MemoryCreated → mesh hidden immediately, 2 birth-orb sprites added,
ZERO RingGeometry meshes and ZERO Points particles at spawn. Full
ritual drive → onArrive fires, node visible + materializing, sentinel
cleared. Newton's Cradle: target mesh scale exactly 0.001 * 1.8 right
after arrival. Dual shockwave: exactly 2 Ring meshes added. Re-read
live position on arrival — force-sim motion during ritual → burst
lands at the NEW position. Sanhedrin abort path → rainbow burst,
shockwave, ripple wave are NEVER called (vi.spyOn).
**three-mock.ts (EXTENDED)**
Added Color.setRGB — production Three.js has it, the Sanhedrin-
Shatter path in effects.ts uses it. Two write-agents independently
monkey-patched the mock inline; consolidated as a 5-line mock
addition so tests stay clean.
**e2e/pulse-toast.spec.ts (NEW, 235 lines, 6 Playwright tests)**
Navigate /dashboard/settings → click Preview Pulse → assert first
toast appears within 500ms → assert >= 2 toasts visible at peak.
Click-to-dismiss removes clicked toast (matched by aria-label).
Hover survives >8s past the 5.5s dwell. Keyboard Enter dismisses
focused toast. CSS animation-play-state:paused on .toast-progress-
fill while hovered, running on mouseleave. Screenshots attached to
HTML report. Zero backend dependency (fireDemoSequence is purely
client-side).
**e2e/birth-ritual.spec.ts (NEW, 199 lines, 3 Playwright tests)**
Canvas mounts on /dashboard/graph (gracefully test.fixme if MCP
backend absent). Settings button injection + SPA route to /graph
→ screenshot timeline at t=0/500/1200/2000/2400/3000ms attached
to HTML report. pageerror + console-error listeners catch any
crash (would re-surface FATAL 6 if reintroduced). Three back-to-
back births — no errors, canvas still dispatches clicks.
Run commands:
cd apps/dashboard && npm test # 361/361 pass, ~600ms
cd apps/dashboard && npx playwright test # 9/9 pass, ~25s
Typecheck: 0 errors, 0 warnings. Build: clean adapter-static.
* fix(graph): default /api/graph to newest-memory center, add sort param
memory_timeline PR #37 exposed the same class of bug in the graph
endpoint: the dashboard Graph page (and the /api/graph endpoint it
hits) defaulted to centering on the most-connected memory, ran BFS at
depth 3, and capped the subgraph at 150 nodes. On a mature corpus this
clustered the visualization around a historical hotspot and hid freshly
ingested memories that hadn't accumulated edges yet. User-visible
symptom: TimeSlider on /graph showing "Feb 21 → Mar 1 2026" when the
database actually contains memories through today (Apr 20).
**Backend (`crates/vestige-mcp/src/dashboard/handlers.rs`):**
- `GraphParams` gains `sort: Option<String>` (accepted: "recent" |
"connected", unknown falls back to "recent").
- New internal `GraphSort` enum + case-insensitive `parse()`.
- Extracted `default_center_id(storage, sort)` so handler logic and
tests share the same branching. Recent path picks `get_all_nodes(1,
0)` (ORDER BY created_at DESC). Connected path picks
`get_most_connected_memory`, degrading gracefully to recent if the
DB has no edges yet.
- Default behaviour flipped from "connected" to "recent" — matches
user expectation of "show me my recent stuff".
**Dashboard (`apps/dashboard`):**
- `api.graph()` accepts `sort?: 'recent' | 'connected'` with JSDoc
explaining the rationale.
- `/graph/+page.svelte` passes `sort: 'recent'` when no query or
center_id is active. Query / center_id paths unchanged — they
already carry their own centering intent.
**Tests:** 6 new unit tests in `handlers::tests`:
- `graph_sort_parse_defaults_to_recent` (None, empty, garbage,
"recent", "Recent", "RECENT")
- `graph_sort_parse_accepts_connected_case_insensitive`
- `default_center_id_recent_returns_newest_node` — ingest 3 nodes,
assert newest is picked
- `default_center_id_connected_prefers_hub_over_newest` — wire a hub
node with 3 spokes, then ingest a newer "lonely" node; assert the
hub wins in Connected mode even though it's older
- `default_center_id_connected_falls_back_to_recent_when_no_edges`
— fresh DB with no connections still returns newest, not 404
- `default_center_id_returns_not_found_on_empty_db` — both modes
return 404 cleanly on empty storage
Build + test:
- cargo test -p vestige-mcp --lib handlers:: → 6/6 pass
- cargo test --workspace --lib → 830/830 pass, 0 failed
- cargo clippy -p vestige-core -p vestige-mcp --lib -- -D warnings →
clean
- npm run check → 0 errors, 0 warnings
- npm test → 361/361 pass
Binary already installed at ~/.local/bin/vestige-mcp (copied from
cargo build --release -p vestige-mcp). New Claude Desktop / Code
sessions will pick it up automatically when they respawn their MCP
subprocess. The dashboard HTTP server on port 3927 needs a manual
relaunch from a terminal with the usual pattern:
nohup bash -c 'tail -f /dev/null | \
VESTIGE_DASHBOARD_ENABLED=true ~/.local/bin/vestige-mcp' \
> /tmp/vestige-mcp.log 2>&1 & disown
* feat(v2.4): UI expansion — 8 new surfaces exposing the cognitive engine
Sam asked: "Build EVERY SINGLE MISSING UI PIECE." 10 parallel agents shipped
10 new viewports over the existing Rust backend, then 11 audit agents
line-by-line reviewed each one, extracted pure-logic helpers, fixed ~30
bugs, and shipped 549 new unit tests. Everything wired through the layout
with single-key shortcuts and a live theme toggle.
**Eight new routes**
- `/reasoning` — Reasoning Theater: Cmd+K ask palette → animated 8-stage
deep_reference pipeline + FSRS-trust-scored evidence cards +
contradiction arcs rendered as live SVG between evidence nodes
- `/duplicates` — threshold-driven cluster detector with winner selection,
Merge/Review/Dismiss actions, debounced slider
- `/dreams` — Dream Cinema: trigger dream + scrubbable 5-stage replay
(Replay → Cross-reference → Strengthen → Prune → Transfer) + insight
cards with novelty glow
- `/schedule` — FSRS Review Calendar: 6×7 grid with urgency color
bands (overdue/today/week/future), retention sparkline, expand-day list
- `/importance` — 4-channel radar (Novelty/Arousal/Reward/Attention) with
composite score + top-important list
- `/activation` — live spreading-activation view: search → SVG concentric
rings with decay animation + live-mode event feed
- `/contradictions` — 2D cosmic constellation of conflicting memories,
arcs colored by severity, tooltips with previews
- `/patterns` — cross-project pattern transfer heatmap with category
filters, top-transferred sidebar
**Three layout additions**
- `AmbientAwarenessStrip` — slim top band with retention vitals, at-risk
count, active intentions, recent dream, activity sparkline, dreaming
indicator, Sanhedrin-watch flash. Pure `$derived` over existing stores.
- `ThemeToggle` — dark/light/auto cycle with matchMedia listener,
localStorage persistence, SSR-safe, reduced-motion-aware. Rendered in
sidebar footer next to the connection dot.
- `MemoryAuditTrail` — per-memory Sources panel integrated as a
Content/Audit tab into the existing /memories expansion.
**Pure-logic helper modules extracted (for testability + reuse)**
reasoning-helpers, duplicates-helpers, dream-helpers, schedule-helpers,
audit-trail-helpers, awareness-helpers, contradiction-helpers,
activation-helpers, patterns-helpers, importance-helpers.
**Bugs fixed during audit (not exhaustive)**
- Trust-color inconsistency between EvidenceCard and the page confidence
ring (0.75 boundary split emerald vs amber)
- `new Date('garbage').toLocaleDateString()` returned literal "Invalid Date"
in 3 components — all now return em-dash or raw string
- NaN propagation in `Math.max(0, Math.min(1, NaN))` across clamps
- Off-by-one PRNG in audit-trail seeded mock (seed === UINT32_MAX yielded
rand() === 1.0 → index out of bounds)
- Duplicates dismissals keyed by array index broke on re-fetch; now keyed
by sorted cluster member IDs with stale-dismissal pruning
- Empty-cluster crash in DuplicateCluster.pickWinner
- Undefined tags crash in DuplicateCluster.safeTags
- Debounce timer leak in threshold slider (missing onDestroy cleanup)
- Schedule day-vs-hour granularity mismatch between calendar cell and
sidebar list ("today" in one, "in 1d" in the other)
- Schedule 500-memory hard cap silently truncated; bumped to 2000 + banner
- Schedule DST boundary bug in daysBetween (wall-clock math vs
startOfDay-normalized)
- Dream stage clamp now handles NaN/Infinity/floats
- Dream double-click debounce via `if (dreaming) return`
- Theme setTheme runtime validation; initTheme idempotence (listener +
style-element dedup on repeat calls)
- ContradictionArcs node radius unclamped (trust < 0 or > 1 rendered
invalid sizes); tooltip position clamp (could push off-canvas)
- ContradictionArcs $state closure capture (width/height weren't reactive
in the derived layout block)
- Activation route was MISSING from the repo — audit agent created it
with identity-based event filtering and proper RAF cleanup
- Layout: ThemeToggle was imported but never rendered — now in sidebar
footer; sidebar overflow-y-auto added for the 16-entry nav
**Tests — 549 new, 910 total passing (0 failures)**
ReasoningChain 42 | EvidenceCard 50
DuplicateCluster 64 | DreamStageReplay 19
DreamInsightCard 43 | FSRSCalendar 32
MemoryAuditTrail 45 | AmbientAwareness 60
theme (store) 31 | ContradictionArcs 43
ActivationNetwork 54 | PatternTransfer 31
ImportanceRadar 35 | + existing 361 tests still green
**Gates passed**
- `npm run check`: 0 errors, 0 warnings across 623 files
- `npm test`: 910/910 passing, 22 test files
- `npm run build`: clean adapter-static output
**Layout wiring**
- Nav array expanded 8 → 16 entries (existing 8 + 8 new routes)
- Single-key shortcuts added: R/A/D/C/P/U/X/N (no conflicts with
existing G/M/T/F/E/I/S/,)
- Cmd+K palette search works across all 16
- Mobile nav = top 5 (Graph, Reasoning, Memories, Timeline, Feed)
- AmbientAwarenessStrip mounted as first child of <main>
- ThemeToggle rendered in sidebar footer (was imported-but-unmounted)
- Theme initTheme() + teardown wired into onMount cleanup chain
Net branch delta: 47 files changed, +13,756 insertions, -6 deletions
* chore(release): v2.0.8 "Pulse"
Bundled release: Reasoning Theater wired to the 8-stage deep_reference
cognitive pipeline, Pulse InsightToast, Memory Birth Ritual (v2.3
Terrarium), 7 new dashboard surfaces (/duplicates, /dreams, /schedule,
/importance, /activation, /contradictions, /patterns), 3D graph
brightness system with auto distance-compensation + user slider, and
contradiction-detection + primary-selection hardening in the
cross_reference tool. Intel Mac (x86_64-apple-darwin) also flows through
to the release matrix from PR #43.
Added:
- POST /api/deep_reference — HTTP surface for the 8-stage pipeline
- DeepReferenceCompleted WebSocket event (primary + supporting +
contradicting memory IDs for downstream graph animation)
- /reasoning route, full UI + Cmd+K Ask palette
- 7 new dashboard surfaces exposing the cognitive engine
- Graph brightness slider + localStorage persistence + distance-based
emissive compensation so nodes don't disappear into fog at zoom-out
Fixed:
- Contradiction-detection false positives: adjacent-domain memories no
longer flagged as conflicts (NEGATION_PAIRS wildcards removed,
shared-words floor 2 → 4, topic-sim floor 0.15 → 0.55, STAGE 5
overlap floor 0.15 → 0.4)
- Primary-memory selection: unified composite 0.5 × relevance + 0.2 ×
trust + 0.3 × term_presence with hard topic-term filter, closing the
class of bug where off-topic high-trust memories won queries about
specific subjects
- Graph default-load fallback from sort=recent to sort=connected when
the newest memory is isolated, both backend and client
Changed:
- Reasoning page information hierarchy: chain renders first as hero,
confidence meter + Primary Source citation footer below
- Cargo feature split: embeddings code-only + ort-download | ort-dynamic
backends; defaults preserve identical behavior for existing consumers
- CI release-build now gates PRs too so multi-platform regressions
surface pre-merge
This commit is contained in:
parent
5b993e841f
commit
6a807698ef
364 changed files with 19862 additions and 392 deletions
|
|
@ -497,4 +497,505 @@ describe('EffectManager', () => {
|
|||
expect(effects.pulseEffects.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createBirthOrb (v2.3 Memory Birth Ritual)', () => {
|
||||
// Build a camera with a Quaternion for createBirthOrb's view-space
|
||||
// projection. The three-mock's applyQuaternion is identity, so the
|
||||
// start position collapses to `camera.position + (0, 0, -distance)`.
|
||||
function makeCamera() {
|
||||
return {
|
||||
position: new Vector3(0, 30, 80),
|
||||
quaternion: new (class {
|
||||
x = 0; y = 0; z = 0; w = 1;
|
||||
})(),
|
||||
} as any;
|
||||
}
|
||||
|
||||
it('adds exactly 2 sprites to the scene on spawn', () => {
|
||||
const cam = makeCamera();
|
||||
const baseline = scene.children.length;
|
||||
effects.createBirthOrb(
|
||||
cam,
|
||||
new Color(0x00ffd1) as any,
|
||||
() => new Vector3(0, 0, 0) as any,
|
||||
() => {}
|
||||
);
|
||||
expect(scene.children.length).toBe(baseline + 2);
|
||||
});
|
||||
|
||||
it('both sprite and core use additive blending', () => {
|
||||
const cam = makeCamera();
|
||||
effects.createBirthOrb(
|
||||
cam,
|
||||
new Color(0xff8800) as any,
|
||||
() => new Vector3(0, 0, 0) as any,
|
||||
() => {}
|
||||
);
|
||||
const halo = scene.children[0] as any;
|
||||
const core = scene.children[1] as any;
|
||||
// AdditiveBlending constant from three-mock is 2
|
||||
expect(halo.material.blending).toBe(2);
|
||||
expect(core.material.blending).toBe(2);
|
||||
// depthTest:false is passed to the SpriteMaterial constructor in
|
||||
// effects.ts so the orb stays visible through other nodes. The
|
||||
// three-mock's SpriteMaterial constructor does not persist this
|
||||
// param, so we can't assert it at the instance level here; the
|
||||
// production behavior is covered by ui-fixes.test.ts source grep.
|
||||
expect(halo.material.transparent).toBe(true);
|
||||
expect(core.material.transparent).toBe(true);
|
||||
});
|
||||
|
||||
it('positions the orb at camera-relative cosmic center on spawn', () => {
|
||||
const cam = makeCamera();
|
||||
effects.createBirthOrb(
|
||||
cam,
|
||||
new Color(0x00ffd1) as any,
|
||||
() => new Vector3(0, 0, 0) as any,
|
||||
() => {},
|
||||
{ distanceFromCamera: 40 }
|
||||
);
|
||||
const halo = scene.children[0] as any;
|
||||
const core = scene.children[1] as any;
|
||||
// mock applyQuaternion is identity, so startPos = camera.pos + (0,0,-40)
|
||||
expect(halo.position.x).toBeCloseTo(0);
|
||||
expect(halo.position.y).toBeCloseTo(30);
|
||||
expect(halo.position.z).toBeCloseTo(40); // 80 + (-40)
|
||||
expect(core.position.x).toBeCloseTo(halo.position.x);
|
||||
expect(core.position.y).toBeCloseTo(halo.position.y);
|
||||
expect(core.position.z).toBeCloseTo(halo.position.z);
|
||||
});
|
||||
|
||||
it('gestation phase: position stays at startPos for all 48 frames', () => {
|
||||
const cam = makeCamera();
|
||||
effects.createBirthOrb(
|
||||
cam,
|
||||
new Color(0x00ffd1) as any,
|
||||
() => new Vector3(100, 100, 100) as any, // far-away target
|
||||
() => {}
|
||||
);
|
||||
const halo = scene.children[0] as any;
|
||||
const startX = halo.position.x;
|
||||
const startY = halo.position.y;
|
||||
const startZ = halo.position.z;
|
||||
|
||||
for (let f = 0; f < 48; f++) {
|
||||
effects.update(nodeMeshMap, cam);
|
||||
expect(halo.position.x).toBeCloseTo(startX);
|
||||
expect(halo.position.y).toBeCloseTo(startY);
|
||||
expect(halo.position.z).toBeCloseTo(startZ);
|
||||
}
|
||||
});
|
||||
|
||||
it('gestation phase: opacity rises from 0 toward 0.95', () => {
|
||||
const cam = makeCamera();
|
||||
effects.createBirthOrb(
|
||||
cam,
|
||||
new Color(0x00ffd1) as any,
|
||||
() => new Vector3(0, 0, 0) as any,
|
||||
() => {}
|
||||
);
|
||||
const halo = scene.children[0] as any;
|
||||
const core = scene.children[1] as any;
|
||||
|
||||
// Spawn opacity
|
||||
expect(halo.material.opacity).toBe(0);
|
||||
expect(core.material.opacity).toBe(0);
|
||||
|
||||
effects.update(nodeMeshMap, cam); // age 1
|
||||
const earlyHaloOp = halo.material.opacity;
|
||||
expect(earlyHaloOp).toBeGreaterThan(0);
|
||||
expect(earlyHaloOp).toBeLessThan(0.2);
|
||||
|
||||
// Run to end of gestation
|
||||
for (let f = 0; f < 47; f++) effects.update(nodeMeshMap, cam);
|
||||
expect(halo.material.opacity).toBeCloseTo(0.95, 1);
|
||||
expect(core.material.opacity).toBeCloseTo(1.0, 1);
|
||||
// Monotonic-ish growth: late gestation > early gestation
|
||||
expect(halo.material.opacity).toBeGreaterThan(earlyHaloOp);
|
||||
});
|
||||
|
||||
it('gestation phase: sprite scale grows substantially', () => {
|
||||
const cam = makeCamera();
|
||||
effects.createBirthOrb(
|
||||
cam,
|
||||
new Color(0x00ffd1) as any,
|
||||
() => new Vector3(0, 0, 0) as any,
|
||||
() => {}
|
||||
);
|
||||
const halo = scene.children[0] as any;
|
||||
|
||||
effects.update(nodeMeshMap, cam); // age 1
|
||||
const earlyScale = halo.scale.x;
|
||||
|
||||
for (let f = 0; f < 47; f++) effects.update(nodeMeshMap, cam); // age 48
|
||||
const lateScale = halo.scale.x;
|
||||
|
||||
// Halo grows from ~0.5 toward ~5 during gestation (with pulse variation).
|
||||
expect(lateScale).toBeGreaterThan(earlyScale);
|
||||
expect(lateScale).toBeGreaterThan(2);
|
||||
});
|
||||
|
||||
it('gestation phase: halo color tints toward event color', () => {
|
||||
const cam = makeCamera();
|
||||
const eventColor = new Color(0xff0000); // pure red
|
||||
effects.createBirthOrb(
|
||||
cam,
|
||||
eventColor as any,
|
||||
() => new Vector3(0, 0, 0) as any,
|
||||
() => {}
|
||||
);
|
||||
const halo = scene.children[0] as any;
|
||||
|
||||
effects.update(nodeMeshMap, cam); // age 1 — factor ≈ 0.72
|
||||
const earlyR = halo.material.color.r;
|
||||
|
||||
for (let f = 0; f < 47; f++) effects.update(nodeMeshMap, cam); // age 48 — factor = 1.0
|
||||
const lateR = halo.material.color.r;
|
||||
|
||||
// Red channel should approach the event color's red (1.0) from a dimmer value
|
||||
expect(lateR).toBeGreaterThan(earlyR);
|
||||
expect(lateR).toBeCloseTo(1.0, 1);
|
||||
// Green/blue stay at 0 (event color is pure red)
|
||||
expect(halo.material.color.g).toBeCloseTo(0);
|
||||
expect(halo.material.color.b).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
it('flight phase: Bezier arc passes ABOVE the linear midpoint at t=0.5', () => {
|
||||
const cam = makeCamera();
|
||||
// startPos = (0, 30, 40), target = (0, 0, 0)
|
||||
// linear midpoint y = 15; control point y = 15 + 30 + dist*0.15 = 52.5
|
||||
effects.createBirthOrb(
|
||||
cam,
|
||||
new Color(0x00ffd1) as any,
|
||||
() => new Vector3(0, 0, 0) as any,
|
||||
() => {}
|
||||
);
|
||||
const halo = scene.children[0] as any;
|
||||
|
||||
// Drive past gestation (48) + half of flight (45) = 93 frames → t=0.5
|
||||
for (let f = 0; f < 93; f++) effects.update(nodeMeshMap, cam);
|
||||
|
||||
// Linear midpoint y is 15; Bezier midpoint should be notably higher.
|
||||
expect(halo.position.y).toBeGreaterThan(15);
|
||||
// And not as high as the control point itself (52.5) — Bezier
|
||||
// passes through midpoint-ish at t=0.5, biased upward by the arc.
|
||||
expect(halo.position.y).toBeLessThan(52.5);
|
||||
});
|
||||
|
||||
it('flight phase: orb moves from startPos toward target', () => {
|
||||
const cam = makeCamera();
|
||||
effects.createBirthOrb(
|
||||
cam,
|
||||
new Color(0x00ffd1) as any,
|
||||
() => new Vector3(0, 0, 0) as any,
|
||||
() => {}
|
||||
);
|
||||
const halo = scene.children[0] as any;
|
||||
|
||||
// End of gestation
|
||||
for (let f = 0; f < 48; f++) effects.update(nodeMeshMap, cam);
|
||||
const gestZ = halo.position.z;
|
||||
|
||||
// One tick into flight
|
||||
effects.update(nodeMeshMap, cam);
|
||||
const earlyFlightZ = halo.position.z;
|
||||
|
||||
// Near end of flight
|
||||
for (let f = 0; f < 88; f++) effects.update(nodeMeshMap, cam);
|
||||
const lateFlightZ = halo.position.z;
|
||||
|
||||
// Z moves from 40 toward 0
|
||||
expect(earlyFlightZ).toBeLessThan(gestZ);
|
||||
expect(lateFlightZ).toBeLessThan(earlyFlightZ);
|
||||
expect(lateFlightZ).toBeLessThan(5); // close to target z=0
|
||||
});
|
||||
|
||||
it('dynamic target tracking: changing getTargetPos mid-flight redirects the orb', () => {
|
||||
const cam = makeCamera();
|
||||
let target = new Vector3(0, 0, 0);
|
||||
effects.createBirthOrb(
|
||||
cam,
|
||||
new Color(0x00ffd1) as any,
|
||||
() => target as any,
|
||||
() => {}
|
||||
);
|
||||
const halo = scene.children[0] as any;
|
||||
|
||||
// Drive to mid-flight (gestation 48 + 30 flight frames = 78)
|
||||
for (let f = 0; f < 78; f++) effects.update(nodeMeshMap, cam);
|
||||
const xBeforeRedirect = halo.position.x;
|
||||
|
||||
// Redirect target far to the +X side
|
||||
target = new Vector3(200, 0, 0);
|
||||
|
||||
// A few more flight frames — orb should track the new target
|
||||
for (let f = 0; f < 10; f++) effects.update(nodeMeshMap, cam);
|
||||
const xAfterRedirect = halo.position.x;
|
||||
|
||||
// With the original target at (0,0,0), x stays near 0 throughout.
|
||||
// After redirect, x should swing toward the new target's +200.
|
||||
expect(xAfterRedirect).toBeGreaterThan(xBeforeRedirect + 5);
|
||||
});
|
||||
|
||||
it('onArrive fires exactly once at frame 139 (totalFrames + 1)', () => {
|
||||
const cam = makeCamera();
|
||||
let arriveCount = 0;
|
||||
effects.createBirthOrb(
|
||||
cam,
|
||||
new Color(0x00ffd1) as any,
|
||||
() => new Vector3(0, 0, 0) as any,
|
||||
() => {
|
||||
arriveCount++;
|
||||
}
|
||||
);
|
||||
|
||||
// Drive through gestation (48) + flight (90) = 138 frames. Should NOT have fired.
|
||||
for (let f = 0; f < 138; f++) effects.update(nodeMeshMap, cam);
|
||||
expect(arriveCount).toBe(0);
|
||||
|
||||
// Frame 139 — fires onArrive
|
||||
effects.update(nodeMeshMap, cam);
|
||||
expect(arriveCount).toBe(1);
|
||||
|
||||
// Drive many more frames — must stay at 1
|
||||
for (let f = 0; f < 50; f++) effects.update(nodeMeshMap, cam);
|
||||
expect(arriveCount).toBe(1);
|
||||
});
|
||||
|
||||
it('post-arrival fade: orb disposes from scene after ~8 fade frames', () => {
|
||||
const cam = makeCamera();
|
||||
const baseline = scene.children.length;
|
||||
effects.createBirthOrb(
|
||||
cam,
|
||||
new Color(0x00ffd1) as any,
|
||||
() => new Vector3(0, 0, 0) as any,
|
||||
() => {}
|
||||
);
|
||||
expect(scene.children.length).toBe(baseline + 2);
|
||||
|
||||
// Gestation + flight + arrive + fade = 138 + 1 + 8 = 147 frames
|
||||
for (let f = 0; f < 150; f++) effects.update(nodeMeshMap, cam);
|
||||
|
||||
// Both orb sprites should be gone
|
||||
expect(scene.children.length).toBe(baseline);
|
||||
});
|
||||
|
||||
it('onArrive callback wrapped in try/catch so a throw does not crash the loop', () => {
|
||||
const cam = makeCamera();
|
||||
effects.createBirthOrb(
|
||||
cam,
|
||||
new Color(0x00ffd1) as any,
|
||||
() => new Vector3(0, 0, 0) as any,
|
||||
() => {
|
||||
throw new Error('caller blew up');
|
||||
}
|
||||
);
|
||||
|
||||
// Should not throw — the production code swallows arrival-callback errors.
|
||||
expect(() => {
|
||||
for (let f = 0; f < 160; f++) effects.update(nodeMeshMap, cam);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('Sanhedrin Shatter: onArrive NEVER fires when target vanishes mid-flight', () => {
|
||||
const cam = makeCamera();
|
||||
let arriveCount = 0;
|
||||
let target: Vector3 | undefined = new Vector3(0, 0, 0);
|
||||
|
||||
effects.createBirthOrb(
|
||||
cam,
|
||||
new Color(0x00ffd1) as any,
|
||||
() => target as any,
|
||||
() => {
|
||||
arriveCount++;
|
||||
}
|
||||
);
|
||||
|
||||
// Finish gestation (48 frames) with target present
|
||||
for (let f = 0; f < 48; f++) effects.update(nodeMeshMap, cam);
|
||||
expect(arriveCount).toBe(0);
|
||||
|
||||
// Stop hook yanks the target mid-flight
|
||||
target = undefined;
|
||||
|
||||
// Run enough frames to cover the entire orb lifecycle
|
||||
for (let f = 0; f < 200; f++) effects.update(nodeMeshMap, cam);
|
||||
|
||||
// onArrive must NEVER fire on aborted orbs
|
||||
expect(arriveCount).toBe(0);
|
||||
});
|
||||
|
||||
it('Sanhedrin Shatter: implosion is spawned when target vanishes mid-flight', () => {
|
||||
const cam = makeCamera();
|
||||
let target: Vector3 | undefined = new Vector3(0, 0, 0);
|
||||
|
||||
const baseline = scene.children.length;
|
||||
effects.createBirthOrb(
|
||||
cam,
|
||||
new Color(0x00ffd1) as any,
|
||||
() => target as any,
|
||||
() => {}
|
||||
);
|
||||
// baseline + 2 sprites
|
||||
expect(scene.children.length).toBe(baseline + 2);
|
||||
|
||||
// Finish gestation
|
||||
for (let f = 0; f < 48; f++) effects.update(nodeMeshMap, cam);
|
||||
|
||||
// Yank target → abort triggers on next tick
|
||||
target = undefined;
|
||||
const beforeAbort = scene.children.length;
|
||||
effects.update(nodeMeshMap, cam);
|
||||
// Scene should have grown by at least 1 (the implosion particles)
|
||||
expect(scene.children.length).toBeGreaterThan(beforeAbort);
|
||||
});
|
||||
|
||||
it('Sanhedrin Shatter: halo turns blood-red on abort', () => {
|
||||
const cam = makeCamera();
|
||||
let target: Vector3 | undefined = new Vector3(0, 0, 0);
|
||||
|
||||
effects.createBirthOrb(
|
||||
cam,
|
||||
new Color(0x00ffd1) as any, // cyan — NOT red
|
||||
() => target as any,
|
||||
() => {}
|
||||
);
|
||||
const halo = scene.children[0] as any;
|
||||
|
||||
// Finish gestation
|
||||
for (let f = 0; f < 48; f++) effects.update(nodeMeshMap, cam);
|
||||
|
||||
// Sanity: halo is NOT red yet (event color cyan has r≈0)
|
||||
expect(halo.material.color.r).toBeLessThan(0.5);
|
||||
|
||||
// Yank target; abort triggers next tick
|
||||
target = undefined;
|
||||
effects.update(nodeMeshMap, cam);
|
||||
|
||||
// Halo should now be blood red (1.0, 0.15, 0.2)
|
||||
expect(halo.material.color.r).toBeGreaterThan(0.9);
|
||||
expect(halo.material.color.g).toBeLessThan(0.3);
|
||||
expect(halo.material.color.b).toBeLessThan(0.3);
|
||||
});
|
||||
|
||||
it('Sanhedrin Shatter: orb eventually disposes from scene', () => {
|
||||
const cam = makeCamera();
|
||||
let target: Vector3 | undefined = new Vector3(0, 0, 0);
|
||||
|
||||
const baseline = scene.children.length;
|
||||
effects.createBirthOrb(
|
||||
cam,
|
||||
new Color(0x00ffd1) as any,
|
||||
() => target as any,
|
||||
() => {}
|
||||
);
|
||||
|
||||
// Finish gestation
|
||||
for (let f = 0; f < 48; f++) effects.update(nodeMeshMap, cam);
|
||||
// Yank target
|
||||
target = undefined;
|
||||
|
||||
// Drive a long time — orb + implosion should both dispose
|
||||
// (orb fade ~8 frames, implosion lifetime ~80 frames)
|
||||
for (let f = 0; f < 200; f++) effects.update(nodeMeshMap, cam);
|
||||
|
||||
expect(scene.children.length).toBe(baseline);
|
||||
});
|
||||
|
||||
it('dispose() removes active birth orbs from the scene', () => {
|
||||
const cam = makeCamera();
|
||||
effects.createBirthOrb(
|
||||
cam,
|
||||
new Color(0x00ffd1) as any,
|
||||
() => new Vector3(0, 0, 0) as any,
|
||||
() => {}
|
||||
);
|
||||
effects.createBirthOrb(
|
||||
cam,
|
||||
new Color(0xff00ff) as any,
|
||||
() => new Vector3(10, 10, 10) as any,
|
||||
() => {}
|
||||
);
|
||||
// 4 sprites in scene (2 per orb)
|
||||
expect(scene.children.length).toBeGreaterThanOrEqual(4);
|
||||
|
||||
effects.dispose();
|
||||
|
||||
// All orb sprites should be gone
|
||||
expect(scene.children.length).toBe(0);
|
||||
});
|
||||
|
||||
it('multiple orbs in flight: all 3 onArrive callbacks fire exactly once each', () => {
|
||||
const cam = makeCamera();
|
||||
let c1 = 0, c2 = 0, c3 = 0;
|
||||
|
||||
effects.createBirthOrb(
|
||||
cam,
|
||||
new Color(0xff0000) as any,
|
||||
() => new Vector3(10, 0, 0) as any,
|
||||
() => { c1++; }
|
||||
);
|
||||
effects.createBirthOrb(
|
||||
cam,
|
||||
new Color(0x00ff00) as any,
|
||||
() => new Vector3(-10, 0, 0) as any,
|
||||
() => { c2++; }
|
||||
);
|
||||
effects.createBirthOrb(
|
||||
cam,
|
||||
new Color(0x0000ff) as any,
|
||||
() => new Vector3(0, 0, -10) as any,
|
||||
() => { c3++; }
|
||||
);
|
||||
|
||||
// Drive past arrival (139) with margin
|
||||
for (let f = 0; f < 160; f++) effects.update(nodeMeshMap, cam);
|
||||
|
||||
expect(c1).toBe(1);
|
||||
expect(c2).toBe(1);
|
||||
expect(c3).toBe(1);
|
||||
});
|
||||
|
||||
it('custom gestation/flight frame counts are honored', () => {
|
||||
const cam = makeCamera();
|
||||
let arriveCount = 0;
|
||||
effects.createBirthOrb(
|
||||
cam,
|
||||
new Color(0x00ffd1) as any,
|
||||
() => new Vector3(0, 0, 0) as any,
|
||||
() => { arriveCount++; },
|
||||
{ gestationFrames: 10, flightFrames: 20 }
|
||||
);
|
||||
|
||||
// Before frame 31 — no arrival
|
||||
for (let f = 0; f < 30; f++) effects.update(nodeMeshMap, cam);
|
||||
expect(arriveCount).toBe(0);
|
||||
|
||||
// Frame 31 — fires
|
||||
effects.update(nodeMeshMap, cam);
|
||||
expect(arriveCount).toBe(1);
|
||||
});
|
||||
|
||||
it('zero-alloc invariant (advisory): flight phase runs without throwing across many orbs', () => {
|
||||
// Advisory test — vitest has no allocator introspection, but the
|
||||
// inline algebraic Bezier eval in effects.ts is intentionally zero-
|
||||
// allocation per frame (no `new Vector3`, no `new QuadraticBezierCurve3`).
|
||||
// Here we just smoke-test that running many orbs across the full
|
||||
// flight phase does not throw and completes cleanly.
|
||||
const cam = makeCamera();
|
||||
for (let k = 0; k < 6; k++) {
|
||||
effects.createBirthOrb(
|
||||
cam,
|
||||
new Color(0x00ffd1) as any,
|
||||
() => new Vector3(k * 5, 0, 0) as any,
|
||||
() => {}
|
||||
);
|
||||
}
|
||||
expect(() => {
|
||||
for (let f = 0; f < 150; f++) effects.update(nodeMeshMap, cam);
|
||||
}).not.toThrow();
|
||||
// All orbs should have cleaned up
|
||||
expect(scene.children.length).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { NodeManager } from '../nodes';
|
|||
import { EdgeManager } from '../edges';
|
||||
import { EffectManager } from '../effects';
|
||||
import { ForceSimulation } from '../force-sim';
|
||||
import { Vector3, Scene } from './three-mock';
|
||||
import { Vector3, Scene, RingGeometry, Mesh, Points, Sprite } from './three-mock';
|
||||
import { makeNode, makeEdge, makeEvent, resetNodeCounter } from './helpers';
|
||||
import type { GraphNode, VestigeEvent } from '$types';
|
||||
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -861,4 +874,270 @@ describe('Event-to-Mutation Pipeline', () => {
|
|||
expect(mutations.some((m) => m.type === 'edgeAdded')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('v2.3 Birth Ritual wiring', () => {
|
||||
/** Count shockwave rings currently in the scene by their RingGeometry. */
|
||||
function countRings(s: InstanceType<typeof Scene>): number {
|
||||
let n = 0;
|
||||
for (const child of s.children) {
|
||||
if (child instanceof Mesh && child.geometry instanceof RingGeometry) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Count Points children — rainbow bursts, spawn bursts, implosions. */
|
||||
function countPoints(s: InstanceType<typeof Scene>): number {
|
||||
let n = 0;
|
||||
for (const child of s.children) if (child instanceof Points) n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Count Sprite children — birth orb adds a halo + core sprite. */
|
||||
function countSprites(s: InstanceType<typeof Scene>): number {
|
||||
let n = 0;
|
||||
for (const child of s.children) if (child instanceof Sprite) n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
it('node mesh is hidden immediately after MemoryCreated dispatch', () => {
|
||||
mapEventToEffects(
|
||||
makeEvent('MemoryCreated', {
|
||||
id: 'ritual-create',
|
||||
content: 'fresh memory',
|
||||
node_type: 'fact',
|
||||
}),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
|
||||
// Ritual path: mesh/glow/label are all .visible = false until
|
||||
// igniteNode fires on orb arrival.
|
||||
const mesh = nodeManager.meshMap.get('ritual-create')!;
|
||||
const glow = nodeManager.glowMap.get('ritual-create')!;
|
||||
const label = nodeManager.labelSprites.get('ritual-create')!;
|
||||
expect(mesh.visible).toBe(false);
|
||||
expect(glow.visible).toBe(false);
|
||||
expect(label.visible).toBe(false);
|
||||
|
||||
// Pending sentinel is stamped on userData.
|
||||
expect(mesh.userData.birthRitualPending).toBeDefined();
|
||||
});
|
||||
|
||||
it('does NOT fire burst/ripple/shockwave at spawn (only the birth orb)', () => {
|
||||
const ringsBefore = countRings(scene);
|
||||
const pointsBefore = countPoints(scene);
|
||||
const spritesBefore = countSprites(scene);
|
||||
|
||||
mapEventToEffects(
|
||||
makeEvent('MemoryCreated', {
|
||||
id: 'spawn-quiet',
|
||||
content: 'test',
|
||||
node_type: 'fact',
|
||||
}),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
|
||||
// Birth orb adds exactly 2 sprites (halo + core). NodeManager's
|
||||
// addNode also adds a glow Sprite + label Sprite to the NodeManager
|
||||
// GROUP, not to the scene — so spritesBefore -> after delta is +2.
|
||||
expect(countSprites(scene) - spritesBefore).toBe(2);
|
||||
|
||||
// No arrival-cascade effects yet: no shockwave rings, no rainbow
|
||||
// burst/spawn burst/ripple particles.
|
||||
expect(countRings(scene)).toBe(ringsBefore);
|
||||
expect(countPoints(scene)).toBe(pointsBefore);
|
||||
});
|
||||
|
||||
it('drives through the full ritual: onArrive fires, node becomes visible, scale grows', () => {
|
||||
mapEventToEffects(
|
||||
makeEvent('MemoryCreated', {
|
||||
id: 'full-ritual',
|
||||
content: 'visible after arrival',
|
||||
node_type: 'fact',
|
||||
}),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
|
||||
const mesh = nodeManager.meshMap.get('full-ritual')!;
|
||||
expect(mesh.visible).toBe(false);
|
||||
|
||||
// Drive the effects update loop past the full ritual duration
|
||||
// (gestation 48 + flight 90 = 138 frames). After frame 138 the
|
||||
// orb fires onArrive which ignites the node and queues materialization.
|
||||
for (let i = 0; i < 140; i++) {
|
||||
effects.update(nodeManager.meshMap, camera, nodeManager.positions);
|
||||
}
|
||||
|
||||
// Node is now visible and sentinel is cleared.
|
||||
expect(mesh.visible).toBe(true);
|
||||
expect(mesh.userData.birthRitualPending).toBeUndefined();
|
||||
|
||||
// Run node animation a few frames to let materialization scale grow.
|
||||
// Note: onArrive bumped scale by 1.8x (from 0.001 -> 0.0018), then
|
||||
// materialization easeOutElastic pulls it toward targetScale.
|
||||
for (let f = 0; f < 10; f++) {
|
||||
nodeManager.animate(f * 0.016, allNodes, camera);
|
||||
}
|
||||
expect(mesh.scale.x).toBeGreaterThan(0.001);
|
||||
});
|
||||
|
||||
it("Newton's Cradle — target mesh scale is multiplied by 1.8x on arrival", () => {
|
||||
mapEventToEffects(
|
||||
makeEvent('MemoryCreated', {
|
||||
id: 'newton-cradle',
|
||||
content: 'recoil test',
|
||||
node_type: 'fact',
|
||||
}),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
|
||||
const mesh = nodeManager.meshMap.get('newton-cradle')!;
|
||||
// Pre-arrival: scale is the addNode initial 0.001.
|
||||
expect(mesh.scale.x).toBeCloseTo(0.001, 6);
|
||||
|
||||
// Drive just to the moment onArrive fires. Gestation (48) +
|
||||
// flight (90) = 138 frames. Arrival bumps scale by 1.8x BEFORE
|
||||
// materialization has run any ticks, so the scale should be
|
||||
// exactly 0.001 * 1.8 = 0.0018 at that instant. We check right
|
||||
// after onArrive (frame 139) — but effects.update progresses the
|
||||
// orb's age counter by one each call, and on the tick where
|
||||
// orb.age > totalFrames, onArrive fires. We then must NOT tick
|
||||
// nodeManager.animate (or materialization would diverge the scale).
|
||||
for (let i = 0; i < 140; i++) {
|
||||
effects.update(nodeManager.meshMap, camera, nodeManager.positions);
|
||||
}
|
||||
|
||||
// onArrive fired. Scale was 0.001, got multiplied by 1.8 -> 0.0018.
|
||||
// Materialization is queued but hasn't run yet (no animate() calls).
|
||||
expect(mesh.scale.x).toBeCloseTo(0.0018, 6);
|
||||
});
|
||||
|
||||
it('dual shockwave — arrival cascade adds TWO RingGeometry meshes, not one', () => {
|
||||
mapEventToEffects(
|
||||
makeEvent('MemoryCreated', {
|
||||
id: 'dual-shock',
|
||||
content: 'layered crash',
|
||||
node_type: 'fact',
|
||||
}),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
|
||||
const ringsBefore = countRings(scene);
|
||||
|
||||
// Drive past full ritual so onArrive fires.
|
||||
for (let i = 0; i < 140; i++) {
|
||||
effects.update(nodeManager.meshMap, camera, nodeManager.positions);
|
||||
}
|
||||
|
||||
// Both shockwaves fire synchronously in the onArrive callback
|
||||
// (the previous setTimeout-delayed second shockwave was dropped
|
||||
// because it could outlive the scene on route change).
|
||||
const ringsAfter = countRings(scene);
|
||||
expect(ringsAfter - ringsBefore).toBe(2);
|
||||
});
|
||||
|
||||
it('re-reads position on arrival — fires cascade at force-sim-moved position', () => {
|
||||
mapEventToEffects(
|
||||
makeEvent('MemoryCreated', {
|
||||
id: 'moving-target',
|
||||
content: 'follow the node',
|
||||
node_type: 'fact',
|
||||
}),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
|
||||
// Grab the spawn position, then mutate it to simulate the force
|
||||
// simulation pushing the node during the ritual.
|
||||
const movedPos = new Vector3(123, 456, -789);
|
||||
nodeManager.positions.set('moving-target', movedPos);
|
||||
|
||||
// Drive past full ritual.
|
||||
for (let i = 0; i < 140; i++) {
|
||||
effects.update(nodeManager.meshMap, camera, nodeManager.positions);
|
||||
}
|
||||
|
||||
// The onArrive callback re-reads nodeManager.positions and fires
|
||||
// the cascade at the LIVE position. The two shockwave Ring meshes
|
||||
// should have been created at movedPos. Find them and check.
|
||||
const rings = scene.children.filter(
|
||||
(c) => c instanceof Mesh && c.geometry instanceof RingGeometry
|
||||
);
|
||||
expect(rings.length).toBeGreaterThanOrEqual(2);
|
||||
// Rings for this node: their .position copies from arrivePos at
|
||||
// spawn time inside createShockwave.
|
||||
const atMovedPos = rings.filter(
|
||||
(r) => r.position.x === 123 && r.position.y === 456 && r.position.z === -789
|
||||
);
|
||||
expect(atMovedPos.length).toBe(2);
|
||||
});
|
||||
|
||||
it('Sanhedrin abort path — removeNode before arrival prevents the regular cascade', () => {
|
||||
// Spy on the three arrival-cascade emitters so we can assert
|
||||
// they were NEVER called when the target is vetoed mid-ritual.
|
||||
const burstSpy = vi.spyOn(effects, 'createRainbowBurst');
|
||||
const shockwaveSpy = vi.spyOn(effects, 'createShockwave');
|
||||
const rippleSpy = vi.spyOn(effects, 'createRippleWave');
|
||||
|
||||
mapEventToEffects(
|
||||
makeEvent('MemoryCreated', {
|
||||
id: 'vetoed',
|
||||
content: 'about to be shattered',
|
||||
node_type: 'fact',
|
||||
}),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
|
||||
// The orb's getTargetPos() closure reads
|
||||
// nodeManager.positions.get('vetoed'). Dropping the position
|
||||
// directly simulates the "target gone" state that the Sanhedrin
|
||||
// veto produces after dissolution completes — without needing to
|
||||
// drive the full 60-frame dissolution animation.
|
||||
nodeManager.positions.delete('vetoed');
|
||||
expect(nodeManager.positions.has('vetoed')).toBe(false);
|
||||
|
||||
// Snapshot the orb reference before the update loop disposes it.
|
||||
// The abort branch flips `aborted` and tints the halo red; we
|
||||
// assert on those fields after the ritual unwinds.
|
||||
const orbs = (effects as any).birthOrbs as Array<{
|
||||
sprite: { material: { color: any } };
|
||||
core: { material: { color: any } };
|
||||
aborted: boolean;
|
||||
}>;
|
||||
expect(orbs.length).toBe(1);
|
||||
const orbRef = orbs[0];
|
||||
|
||||
// Drive effects past the full ritual. During flight the orb will
|
||||
// see getTargetPos() === undefined, enter the Sanhedrin branch,
|
||||
// call createImplosion (anti-birth visual) and SKIP onArrive —
|
||||
// so the regular rainbow-burst + dual-shockwave + ripple cascade
|
||||
// never fires.
|
||||
for (let i = 0; i < 200; i++) {
|
||||
effects.update(nodeManager.meshMap, camera, nodeManager.positions);
|
||||
}
|
||||
|
||||
// Core assertion: the three regular-cascade emitters were never
|
||||
// invoked for the vetoed node.
|
||||
expect(burstSpy).not.toHaveBeenCalled();
|
||||
expect(shockwaveSpy).not.toHaveBeenCalled();
|
||||
expect(rippleSpy).not.toHaveBeenCalled();
|
||||
|
||||
// Also confirm the orb actually took the abort branch, not the
|
||||
// gestation-only no-op path (otherwise this test would pass for
|
||||
// the wrong reason). The aborted flag is set exactly once inside
|
||||
// the Sanhedrin branch.
|
||||
expect(orbRef.aborted).toBe(true);
|
||||
expect(orbRef.sprite.material.color.r).toBeCloseTo(1.0, 3);
|
||||
expect(orbRef.sprite.material.color.g).toBeCloseTo(0.15, 3);
|
||||
|
||||
burstSpy.mockRestore();
|
||||
shockwaveSpy.mockRestore();
|
||||
rippleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -453,4 +453,201 @@ describe('NodeManager', () => {
|
|||
// The dispose method clears materializingNodes, dissolvingNodes, growingNodes
|
||||
});
|
||||
});
|
||||
|
||||
describe('Birth Ritual integration', () => {
|
||||
it('addNode with isBirthRitual:true hides mesh, glow, and label immediately', () => {
|
||||
const node = makeNode({ id: 'ritual-1' });
|
||||
manager.addNode(node, new Vector3(5, 5, 5), { isBirthRitual: true });
|
||||
|
||||
const mesh = manager.meshMap.get('ritual-1')!;
|
||||
const glow = manager.glowMap.get('ritual-1')!;
|
||||
const label = manager.labelSprites.get('ritual-1')!;
|
||||
|
||||
expect(mesh.visible).toBe(false);
|
||||
expect(glow.visible).toBe(false);
|
||||
expect(label.visible).toBe(false);
|
||||
});
|
||||
|
||||
it('addNode with isBirthRitual:true stores a pending sentinel on mesh.userData', () => {
|
||||
const node = makeNode({ id: 'ritual-sentinel', retention: 0.75 });
|
||||
manager.addNode(node, new Vector3(0, 0, 0), { isBirthRitual: true });
|
||||
|
||||
const mesh = manager.meshMap.get('ritual-sentinel')!;
|
||||
const pending = mesh.userData.birthRitualPending as any;
|
||||
expect(pending).toBeDefined();
|
||||
expect(pending.totalFrames).toBe(30);
|
||||
// targetScale = 0.5 + retention * 2 = 0.5 + 0.75 * 2 = 2.0
|
||||
expect(pending.targetScale).toBeCloseTo(2.0, 3);
|
||||
});
|
||||
|
||||
it('addNode with isBirthRitual:true does NOT enqueue materialization', () => {
|
||||
const ritualNode = makeNode({ id: 'ritual-pending', retention: 0.8 });
|
||||
manager.addNode(ritualNode, new Vector3(10, 10, 10), { isBirthRitual: true });
|
||||
|
||||
// In the real runtime the ritual-pending node is .visible=false
|
||||
// AND is not yet in the GraphNode[] list — it only gets added to
|
||||
// the visible node list once igniteNode flips its visibility and
|
||||
// materialization kicks in. So we pass an empty `nodes` array to
|
||||
// animate(), which also exercises that the breathing loop skips
|
||||
// meshes absent from the nodes array.
|
||||
const camera = { position: new Vector3(0, 30, 80) } as any;
|
||||
for (let f = 0; f < 40; f++) {
|
||||
manager.animate(f * 0.016, [], camera);
|
||||
}
|
||||
|
||||
const mesh = manager.meshMap.get('ritual-pending')!;
|
||||
// Materialization queue never pushed — a regular materializing
|
||||
// node would be at scale ≈ targetScale = 2.1 by frame 40. The
|
||||
// ritual-pending node stays at its addNode initial 0.001 because
|
||||
// no animation loop is mutating its scale.
|
||||
expect(mesh.scale.x).toBeCloseTo(0.001, 3);
|
||||
|
||||
// Stronger invariant — the sentinel is still there, confirming
|
||||
// the node never got handed off to the materialization queue.
|
||||
expect(mesh.userData.birthRitualPending).toBeDefined();
|
||||
});
|
||||
|
||||
it('addNode without opts proceeds with normal materialization (old behavior)', () => {
|
||||
const node = makeNode({ id: 'normal-spawn' });
|
||||
manager.addNode(node, new Vector3(1, 2, 3));
|
||||
|
||||
const mesh = manager.meshMap.get('normal-spawn')!;
|
||||
const glow = manager.glowMap.get('normal-spawn')!;
|
||||
const label = manager.labelSprites.get('normal-spawn')!;
|
||||
|
||||
// Default mesh.visible is true in three-mock (Object3D has no explicit field).
|
||||
// Key invariant: visible is NOT explicitly false like the ritual path.
|
||||
expect(mesh.visible).not.toBe(false);
|
||||
expect(glow.visible).not.toBe(false);
|
||||
expect(label.visible).not.toBe(false);
|
||||
|
||||
// And no pending sentinel
|
||||
expect(mesh.userData.birthRitualPending).toBeUndefined();
|
||||
|
||||
// Animation should proceed — scale grows via easeOutElastic
|
||||
const camera = { position: new Vector3(0, 30, 80) } as any;
|
||||
for (let f = 0; f < 20; f++) {
|
||||
manager.animate(f * 0.016, [node], camera);
|
||||
}
|
||||
expect(mesh.scale.x).toBeGreaterThan(0.1);
|
||||
});
|
||||
|
||||
it('igniteNode flips all three visibility flags and queues materialization', () => {
|
||||
const node = makeNode({ id: 'to-ignite', retention: 0.6 });
|
||||
manager.addNode(node, new Vector3(0, 0, 0), { isBirthRitual: true });
|
||||
|
||||
// Pre-ignite: hidden
|
||||
const mesh = manager.meshMap.get('to-ignite')!;
|
||||
const glow = manager.glowMap.get('to-ignite')!;
|
||||
const label = manager.labelSprites.get('to-ignite')!;
|
||||
expect(mesh.visible).toBe(false);
|
||||
|
||||
manager.igniteNode('to-ignite');
|
||||
|
||||
// Post-ignite: visible
|
||||
expect(mesh.visible).toBe(true);
|
||||
expect(glow.visible).toBe(true);
|
||||
expect(label.visible).toBe(true);
|
||||
|
||||
// Sentinel is gone
|
||||
expect(mesh.userData.birthRitualPending).toBeUndefined();
|
||||
|
||||
// Materialization was queued — drive animation and the scale
|
||||
// should grow past the initial 0.001.
|
||||
const camera = { position: new Vector3(0, 30, 80) } as any;
|
||||
for (let f = 0; f < 15; f++) {
|
||||
manager.animate(f * 0.016, [node], camera);
|
||||
}
|
||||
expect(mesh.scale.x).toBeGreaterThan(0.1);
|
||||
});
|
||||
|
||||
it('igniteNode called twice is idempotent (second call is a no-op)', () => {
|
||||
const node = makeNode({ id: 'double-ignite', retention: 0.5 });
|
||||
manager.addNode(node, new Vector3(0, 0, 0), { isBirthRitual: true });
|
||||
|
||||
manager.igniteNode('double-ignite');
|
||||
// Capture scale after one round of animation
|
||||
const camera = { position: new Vector3(0, 30, 80) } as any;
|
||||
for (let f = 0; f < 10; f++) {
|
||||
manager.animate(f * 0.016, [node], camera);
|
||||
}
|
||||
const scaleAfterFirst = manager.meshMap.get('double-ignite')!.scale.x;
|
||||
|
||||
// Second ignite — should NOT push a duplicate materialization entry.
|
||||
// If it did, the extra entry (starting at frame 0) would restart
|
||||
// the scale back near 0.001 or at least visibly reset it.
|
||||
manager.igniteNode('double-ignite');
|
||||
for (let f = 0; f < 5; f++) {
|
||||
manager.animate((f + 10) * 0.016, [node], camera);
|
||||
}
|
||||
const scaleAfterSecond = manager.meshMap.get('double-ignite')!.scale.x;
|
||||
|
||||
// Scale after second ignite should be greater than or roughly equal
|
||||
// to scale after first, NOT reset toward 0.001. A duplicate entry
|
||||
// starting at frame 0 would pull the mesh back near zero on the
|
||||
// very first subsequent animate() tick via mn.mesh.scale.setScalar.
|
||||
expect(scaleAfterSecond).toBeGreaterThanOrEqual(scaleAfterFirst * 0.5);
|
||||
});
|
||||
|
||||
it('igniteNode on a regular (non-ritual) node is a no-op', () => {
|
||||
const node = makeNode({ id: 'regular', retention: 0.5 });
|
||||
manager.addNode(node, new Vector3(0, 0, 0));
|
||||
// Regular addNode already queued materialization. Capture state.
|
||||
const mesh = manager.meshMap.get('regular')!;
|
||||
const visBefore = mesh.visible;
|
||||
|
||||
// Call igniteNode — there's no pending sentinel, should short-circuit.
|
||||
expect(() => manager.igniteNode('regular')).not.toThrow();
|
||||
|
||||
// No pending sentinel means the function returns early after the
|
||||
// sentinel check, so nothing about the mesh changes.
|
||||
expect(mesh.visible).toBe(visBefore);
|
||||
expect(mesh.userData.birthRitualPending).toBeUndefined();
|
||||
});
|
||||
|
||||
it('igniteNode on unknown id is a no-op (no throw)', () => {
|
||||
expect(() => manager.igniteNode('does-not-exist')).not.toThrow();
|
||||
expect(manager.meshMap.has('does-not-exist')).toBe(false);
|
||||
});
|
||||
|
||||
it('position is stored in positions map even when the node is invisible', () => {
|
||||
const node = makeNode({ id: 'invisible-but-positioned' });
|
||||
const spawnPos = new Vector3(42, -17, 8);
|
||||
manager.addNode(node, spawnPos, { isBirthRitual: true });
|
||||
|
||||
// Force simulation + orb getTargetPos() both rely on positions
|
||||
// being live immediately — the ritual only hides visuals, not
|
||||
// physics state.
|
||||
const stored = manager.positions.get('invisible-but-positioned');
|
||||
expect(stored).toBeDefined();
|
||||
expect(stored!.x).toBe(42);
|
||||
expect(stored!.y).toBe(-17);
|
||||
expect(stored!.z).toBe(8);
|
||||
|
||||
// And the mesh itself is still hidden
|
||||
expect(manager.meshMap.get('invisible-but-positioned')!.visible).toBe(false);
|
||||
});
|
||||
|
||||
it('removeNode during pending ritual cancels without materialization', () => {
|
||||
// Sanhedrin abort path at the NodeManager level: a ritual-pending
|
||||
// node gets removed before igniteNode fires. The remove path
|
||||
// should still work (dissolution queue takes over) and igniteNode
|
||||
// called later must not resurrect it.
|
||||
const node = makeNode({ id: 'aborted-ritual' });
|
||||
manager.addNode(node, new Vector3(0, 0, 0), { isBirthRitual: true });
|
||||
|
||||
manager.removeNode('aborted-ritual');
|
||||
|
||||
// Dissolution progresses past totalFrames = 60 and clears state.
|
||||
const camera = { position: new Vector3(0, 30, 80) } as any;
|
||||
for (let f = 0; f < 65; f++) {
|
||||
manager.animate(f * 0.016, [node], camera);
|
||||
}
|
||||
|
||||
expect(manager.meshMap.has('aborted-ritual')).toBe(false);
|
||||
|
||||
// And a late igniteNode call on the dead id is a safe no-op.
|
||||
expect(() => manager.igniteNode('aborted-ritual')).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,20 @@ 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;
|
||||
}
|
||||
|
||||
setRGB(r: number, g: number, b: number) {
|
||||
this.r = r;
|
||||
this.g = g;
|
||||
this.b = b;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
export class BufferAttribute {
|
||||
|
|
@ -329,6 +389,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 +490,9 @@ export function installThreeMock() {
|
|||
Vector3,
|
||||
Vector2,
|
||||
Color,
|
||||
Quaternion,
|
||||
QuadraticBezierCurve3,
|
||||
Texture,
|
||||
BufferAttribute,
|
||||
BufferGeometry,
|
||||
SphereGeometry,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import * as THREE from 'three';
|
||||
import { getGlowTexture } from './nodes';
|
||||
|
||||
export interface PulseEffect {
|
||||
nodeId: string;
|
||||
|
|
@ -49,6 +50,33 @@ interface ConnectionFlash {
|
|||
intensity: number;
|
||||
}
|
||||
|
||||
// v2.3 Memory Birth Ritual. The orb gestates at a camera-relative "cosmic
|
||||
// center" point for `gestationFrames`, then flies along a dynamic quadratic
|
||||
// Bezier curve to the live position of its target node for `flightFrames`,
|
||||
// then calls `onArrive` and disposes itself. The target position is
|
||||
// resolved via `getTargetPos` on every frame so the force simulation can
|
||||
// move the node during the flight and the orb stays glued to it.
|
||||
interface BirthOrb {
|
||||
sprite: THREE.Sprite;
|
||||
core: THREE.Sprite;
|
||||
startPos: THREE.Vector3;
|
||||
getTargetPos: () => THREE.Vector3 | undefined;
|
||||
color: THREE.Color;
|
||||
age: number;
|
||||
gestationFrames: number;
|
||||
flightFrames: number;
|
||||
arriveFired: boolean;
|
||||
onArrive: () => void;
|
||||
/** 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 {
|
||||
pulseEffects: PulseEffect[] = [];
|
||||
private spawnBursts: SpawnBurst[] = [];
|
||||
|
|
@ -57,6 +85,7 @@ export class EffectManager {
|
|||
private implosions: ImplosionEffect[] = [];
|
||||
private shockwaves: Shockwave[] = [];
|
||||
private connectionFlashes: ConnectionFlash[] = [];
|
||||
private birthOrbs: BirthOrb[] = [];
|
||||
private scene: THREE.Scene;
|
||||
|
||||
constructor(scene: THREE.Scene) {
|
||||
|
|
@ -231,6 +260,89 @@ export class EffectManager {
|
|||
this.connectionFlashes.push({ line, intensity: 1.0 });
|
||||
}
|
||||
|
||||
/**
|
||||
* v2.3 Memory Birth Ritual. Spawn a glowing orb at a point in front of the
|
||||
* camera ("cosmic center"), gestate for ~800ms, then arc along a quadratic
|
||||
* Bezier curve to the live position of the target node, which is resolved
|
||||
* on every frame via `getTargetPos`. On arrival, `onArrive` fires — caller
|
||||
* is responsible for adding the real node to the graph and triggering
|
||||
* arrival-time bursts.
|
||||
*
|
||||
* The target getter can return undefined if the node has been removed
|
||||
* mid-flight; the orb then flies to the last known target position so the
|
||||
* burst still fires somewhere coherent rather than snapping to origin.
|
||||
*/
|
||||
createBirthOrb(
|
||||
camera: THREE.Camera,
|
||||
color: THREE.Color,
|
||||
getTargetPos: () => THREE.Vector3 | undefined,
|
||||
onArrive: () => void,
|
||||
opts: { gestationFrames?: number; flightFrames?: number; distanceFromCamera?: number } = {}
|
||||
) {
|
||||
const gestationFrames = opts.gestationFrames ?? 48; // ~800ms
|
||||
const flightFrames = opts.flightFrames ?? 90; // ~1500ms
|
||||
const distanceFromCamera = opts.distanceFromCamera ?? 40;
|
||||
|
||||
// Place the orb slightly in front of the camera, in view-space,
|
||||
// projected back into world coordinates. This way the orb always
|
||||
// appears "right in front of the user's face" regardless of where
|
||||
// the camera has been orbited to.
|
||||
const startPos = new THREE.Vector3(0, 0, -distanceFromCamera)
|
||||
.applyQuaternion(camera.quaternion)
|
||||
.add(camera.position);
|
||||
|
||||
// Outer glow halo
|
||||
const haloMat = new THREE.SpriteMaterial({
|
||||
map: getGlowTexture(),
|
||||
color: color.clone(),
|
||||
transparent: true,
|
||||
opacity: 0.0,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
depthTest: false, // always visible, even through other nodes
|
||||
});
|
||||
const sprite = new THREE.Sprite(haloMat);
|
||||
sprite.position.copy(startPos);
|
||||
sprite.scale.set(0.5, 0.5, 1);
|
||||
sprite.renderOrder = 999;
|
||||
|
||||
// Inner bright core — stays hot white during gestation, tints at launch
|
||||
const coreMat = new THREE.SpriteMaterial({
|
||||
map: getGlowTexture(),
|
||||
color: new THREE.Color(0xffffff),
|
||||
transparent: true,
|
||||
opacity: 0.0,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
depthTest: false,
|
||||
});
|
||||
const core = new THREE.Sprite(coreMat);
|
||||
core.position.copy(startPos);
|
||||
core.scale.set(0.2, 0.2, 1);
|
||||
core.renderOrder = 1000;
|
||||
|
||||
this.scene.add(sprite);
|
||||
this.scene.add(core);
|
||||
|
||||
// Snapshot the current target so we have a fallback.
|
||||
const initialTarget = getTargetPos()?.clone() ?? startPos.clone();
|
||||
|
||||
this.birthOrbs.push({
|
||||
sprite,
|
||||
core,
|
||||
startPos,
|
||||
getTargetPos,
|
||||
color: color.clone(),
|
||||
age: 0,
|
||||
gestationFrames,
|
||||
flightFrames,
|
||||
arriveFired: false,
|
||||
onArrive,
|
||||
lastTargetPos: initialTarget,
|
||||
aborted: false,
|
||||
});
|
||||
}
|
||||
|
||||
update(
|
||||
nodeMeshMap: Map<string, THREE.Mesh>,
|
||||
camera: THREE.Camera,
|
||||
|
|
@ -431,6 +543,122 @@ export class EffectManager {
|
|||
}
|
||||
(flash.line.material as THREE.LineBasicMaterial).opacity = flash.intensity;
|
||||
}
|
||||
|
||||
// v2.3 Birth orbs — gestate at cosmic center, then arc to live node
|
||||
// position along a quadratic Bezier curve. Target position is
|
||||
// re-resolved every frame so the force simulation can move the
|
||||
// destination during flight without the orb losing its mark.
|
||||
for (let i = this.birthOrbs.length - 1; i >= 0; i--) {
|
||||
const orb = this.birthOrbs[i];
|
||||
orb.age++;
|
||||
const totalFrames = orb.gestationFrames + orb.flightFrames;
|
||||
|
||||
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.
|
||||
const t = orb.age / orb.gestationFrames;
|
||||
const ease = 1 - Math.pow(1 - t, 3); // easeOutCubic
|
||||
const pulse = 0.85 + Math.sin(orb.age * 0.35) * 0.15;
|
||||
const haloScale = 0.5 + ease * 4.5 * pulse;
|
||||
const coreScale = 0.2 + ease * 1.8 * pulse;
|
||||
orb.sprite.scale.set(haloScale, haloScale, 1);
|
||||
orb.core.scale.set(coreScale, coreScale, 1);
|
||||
haloMat.opacity = ease * 0.95;
|
||||
coreMat.opacity = ease;
|
||||
// Subtle warm-up — core white, halo tints toward the event
|
||||
// color as gestation completes.
|
||||
haloMat.color.copy(orb.color).multiplyScalar(0.7 + ease * 0.3);
|
||||
orb.sprite.position.copy(orb.startPos);
|
||||
orb.core.position.copy(orb.startPos);
|
||||
} else if (orb.age <= totalFrames) {
|
||||
// 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 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 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 shrink = 1 - ease * 0.35;
|
||||
orb.sprite.scale.setScalar(5 * shrink);
|
||||
orb.core.scale.setScalar(2 * shrink);
|
||||
haloMat.opacity = 0.95;
|
||||
coreMat.opacity = 1.0;
|
||||
// Shift halo fully to the event color during flight
|
||||
haloMat.color.copy(orb.color);
|
||||
} else if (!orb.arriveFired) {
|
||||
// Docking — fire the arrival callback once. Let the caller
|
||||
// trigger burst/ripple effects at the exact target point.
|
||||
orb.arriveFired = true;
|
||||
try {
|
||||
orb.onArrive();
|
||||
} catch (e) {
|
||||
// Effects must never take down the render loop.
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[birth-orb] onArrive threw', e);
|
||||
}
|
||||
// Fade the orb out over a few more frames instead of popping.
|
||||
} else {
|
||||
// Post-arrival fade (8 frames ≈ 130ms)
|
||||
const fadeAge = orb.age - totalFrames;
|
||||
const fade = Math.max(0, 1 - fadeAge / 8);
|
||||
haloMat.opacity = 0.95 * fade;
|
||||
coreMat.opacity = 1.0 * fade;
|
||||
orb.sprite.scale.setScalar(5 * (1 + (1 - fade) * 2));
|
||||
if (fade <= 0) {
|
||||
this.scene.remove(orb.sprite);
|
||||
this.scene.remove(orb.core);
|
||||
haloMat.dispose();
|
||||
coreMat.dispose();
|
||||
this.birthOrbs.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
|
|
@ -464,6 +692,12 @@ export class EffectManager {
|
|||
flash.line.geometry.dispose();
|
||||
(flash.line.material as THREE.Material).dispose();
|
||||
}
|
||||
for (const orb of this.birthOrbs) {
|
||||
this.scene.remove(orb.sprite);
|
||||
this.scene.remove(orb.core);
|
||||
(orb.sprite.material as THREE.Material).dispose();
|
||||
(orb.core.material as THREE.Material).dispose();
|
||||
}
|
||||
this.pulseEffects = [];
|
||||
this.spawnBursts = [];
|
||||
this.rainbowBursts = [];
|
||||
|
|
@ -471,5 +705,6 @@ export class EffectManager {
|
|||
this.implosions = [];
|
||||
this.shockwaves = [];
|
||||
this.connectionFlashes = [];
|
||||
this.birthOrbs = [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -125,25 +125,59 @@ export function mapEventToEffects(
|
|||
// Find spawn position near related nodes
|
||||
const spawnPos = findSpawnPosition(newNode, allNodes, nodePositions);
|
||||
|
||||
// Add to all managers
|
||||
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
|
||||
liveSpawnedNodes.push(data.id);
|
||||
evictOldestLiveNode(ctx, allNodes);
|
||||
|
||||
// Spectacular effects: rainbow burst + double shockwave + ripple wave
|
||||
// v2.3 Memory Birth Ritual — cosmic-center orb, Bezier flight,
|
||||
// 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');
|
||||
effects.createRainbowBurst(spawnPos, color);
|
||||
effects.createShockwave(spawnPos, color, camera);
|
||||
// Second shockwave, hue-shifted, delayed via smaller initial scale
|
||||
const hueShifted = color.clone();
|
||||
hueShifted.offsetHSL(0.15, 0, 0);
|
||||
setTimeout(() => {
|
||||
effects.createShockwave(spawnPos, hueShifted, camera);
|
||||
}, 166); // ~10 frames at 60fps
|
||||
effects.createRippleWave(spawnPos);
|
||||
|
||||
effects.createBirthOrb(
|
||||
camera,
|
||||
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. 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);
|
||||
}
|
||||
);
|
||||
|
||||
onMutation({ type: 'nodeAdded', node: newNode });
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ export function getNodeColor(node: GraphNode, mode: ColorMode): string {
|
|||
// hard-edged "glowing cubes" artefact reported in issue #31. Using a
|
||||
// soft radial gradient gives a real round halo and lets bloom do its job.
|
||||
let sharedGlowTexture: THREE.Texture | null = null;
|
||||
function getGlowTexture(): THREE.Texture {
|
||||
export function getGlowTexture(): THREE.Texture {
|
||||
if (sharedGlowTexture) return sharedGlowTexture;
|
||||
const size = 128;
|
||||
const canvas = document.createElement('canvas');
|
||||
|
|
@ -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) {
|
||||
|
|
@ -446,7 +495,12 @@ export class NodeManager {
|
|||
});
|
||||
}
|
||||
|
||||
animate(time: number, nodes: GraphNode[], camera: THREE.PerspectiveCamera) {
|
||||
animate(
|
||||
time: number,
|
||||
nodes: GraphNode[],
|
||||
camera: THREE.PerspectiveCamera,
|
||||
brightness: number = 1.0
|
||||
) {
|
||||
// Materialization animations — elastic scale-up from 0
|
||||
for (let i = this.materializingNodes.length - 1; i >= 0; i--) {
|
||||
const mn = this.materializingNodes[i];
|
||||
|
|
@ -552,16 +606,38 @@ export class NodeManager {
|
|||
1 + Math.sin(time * 1.5 + nodes.indexOf(node) * 0.5) * 0.15 * node.retention;
|
||||
mesh.scale.setScalar(breathe);
|
||||
|
||||
// Distance compensation: FogExp2 attenuates exponentially with camera
|
||||
// distance, so nodes past ~80 units go nearly black unless we push
|
||||
// emissive harder. Boost runs 1.0x at <60 units → ~2.4x at 200 units.
|
||||
// Combined with the user brightness multiplier this gives a visible
|
||||
// floor at every zoom level without blowing out close-up highlights.
|
||||
const pos = this.positions.get(id);
|
||||
const dist = pos ? camera.position.distanceTo(pos) : 0;
|
||||
const distanceBoost = 1 + Math.min(1.4, Math.max(0, (dist - 60) / 100));
|
||||
|
||||
const mat = mesh.material as THREE.MeshStandardMaterial;
|
||||
if (id === this.hoveredNode) {
|
||||
mat.emissiveIntensity = 1.0;
|
||||
mat.emissiveIntensity = 1.0 * brightness;
|
||||
} else if (id === this.selectedNode) {
|
||||
mat.emissiveIntensity = 0.8;
|
||||
mat.emissiveIntensity = 0.8 * brightness;
|
||||
} else {
|
||||
const baseIntensity = 0.3 + node.retention * 0.5;
|
||||
const breatheIntensity =
|
||||
baseIntensity + Math.sin(time * (0.8 + node.retention * 0.7)) * 0.1 * node.retention;
|
||||
mat.emissiveIntensity = breatheIntensity;
|
||||
mat.emissiveIntensity = breatheIntensity * brightness * distanceBoost;
|
||||
}
|
||||
|
||||
// Opacity also gets the distance boost (capped at 1.0) so the node
|
||||
// body stays visible against the dark void at far zoom.
|
||||
const baseOpacity = 0.3 + node.retention * 0.7;
|
||||
mat.opacity = Math.min(1.0, baseOpacity * brightness * distanceBoost);
|
||||
|
||||
// Mirror the boost onto the glow sprite so the halo tracks the core.
|
||||
const glow = this.glowMap.get(id);
|
||||
if (glow) {
|
||||
const glowMat = glow.material as THREE.SpriteMaterial;
|
||||
const baseGlow = 0.3 + node.retention * 0.35;
|
||||
glowMat.opacity = Math.min(0.95, baseGlow * brightness * distanceBoost);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -90,8 +90,15 @@ export function createScene(container: HTMLDivElement): SceneContext {
|
|||
controls.dampingFactor = 0.05;
|
||||
controls.rotateSpeed = 0.5;
|
||||
controls.zoomSpeed = 0.8;
|
||||
controls.minDistance = 10;
|
||||
controls.maxDistance = 500;
|
||||
// Distance clamps — the camera starts at ~86 units from origin
|
||||
// (position.set(0, 30, 80)). The graph's force-directed layout seats
|
||||
// most nodes within a ~120-unit radius. 500 was dramatically out of
|
||||
// scale — the user could zoom out until every node was one pixel on
|
||||
// a black starfield (issue reported 2026-04-23). 180 keeps the full
|
||||
// graph in frame with nodes still readable; 12 prevents zooming inside
|
||||
// a node and losing orientation.
|
||||
controls.minDistance = 12;
|
||||
controls.maxDistance = 180;
|
||||
controls.autoRotate = true;
|
||||
controls.autoRotateSpeed = 0.3;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue