From cec0781d95cca5e49448fb6d33702b711bcb1e6d Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Wed, 8 Apr 2026 21:24:32 -0400 Subject: [PATCH 1/2] feat: restore frontend shell and implement Project Mnemosyne visual memory bridge --- frontend/index.html | 509 +++++++++++++++++++++++ frontend/js/agent-defs.js | 30 ++ frontend/js/agents.js | 523 ++++++++++++++++++++++++ frontend/js/ambient.js | 212 ++++++++++ frontend/js/avatar.js | 360 +++++++++++++++++ frontend/js/bark.js | 141 +++++++ frontend/js/behaviors.js | 413 +++++++++++++++++++ frontend/js/config.js | 68 ++++ frontend/js/demo.js | 261 ++++++++++++ frontend/js/economy.js | 100 +++++ frontend/js/effects.js | 195 +++++++++ frontend/js/interaction.js | 340 ++++++++++++++++ frontend/js/main.js | 180 +++++++++ frontend/js/presence.js | 139 +++++++ frontend/js/quality.js | 90 +++++ frontend/js/satflow.js | 261 ++++++++++++ frontend/js/scene-objects.js | 756 +++++++++++++++++++++++++++++++++++ frontend/js/storage.js | 39 ++ frontend/js/transcript.js | 183 +++++++++ frontend/js/ui.js | 285 +++++++++++++ frontend/js/visitor.js | 141 +++++++ frontend/js/websocket.js | 689 +++++++++++++++++++++++++++++++ frontend/js/world.js | 95 +++++ frontend/js/zones.js | 161 ++++++++ frontend/style.css | 697 ++++++++++++++++++++++++++++++++ 25 files changed, 6868 insertions(+) create mode 100644 frontend/index.html create mode 100644 frontend/js/agent-defs.js create mode 100644 frontend/js/agents.js create mode 100644 frontend/js/ambient.js create mode 100644 frontend/js/avatar.js create mode 100644 frontend/js/bark.js create mode 100644 frontend/js/behaviors.js create mode 100644 frontend/js/config.js create mode 100644 frontend/js/demo.js create mode 100644 frontend/js/economy.js create mode 100644 frontend/js/effects.js create mode 100644 frontend/js/interaction.js create mode 100644 frontend/js/main.js create mode 100644 frontend/js/presence.js create mode 100644 frontend/js/quality.js create mode 100644 frontend/js/satflow.js create mode 100644 frontend/js/scene-objects.js create mode 100644 frontend/js/storage.js create mode 100644 frontend/js/transcript.js create mode 100644 frontend/js/ui.js create mode 100644 frontend/js/visitor.js create mode 100644 frontend/js/websocket.js create mode 100644 frontend/js/world.js create mode 100644 frontend/js/zones.js create mode 100644 frontend/style.css diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..f7147e3 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,509 @@ + + + + + + + + + + + + + + Timmy Tower World + + + +
INITIALIZING...
+ + +
+
+

TIMMY TOWER WORLD

+
AGENTS: 0
+
JOBS: 0
+
FPS: --
+
+
+
+
+
+ +
+
+
+
+
OFFLINE
+
? HELP
+ +
+
+ + +
+ + + + + diff --git a/frontend/js/agent-defs.js b/frontend/js/agent-defs.js new file mode 100644 index 0000000..2b381dd --- /dev/null +++ b/frontend/js/agent-defs.js @@ -0,0 +1,30 @@ +/** + * agent-defs.js — Single source of truth for all agent definitions. + * + * These are the REAL agents of the Timmy Tower ecosystem. + * Additional agents can join at runtime via the `agent_joined` WS event + * (handled by addAgent() in agents.js). + * + * Fields: + * id — unique string key used in WebSocket messages and state maps + * label — display name shown in the 3D HUD and chat panel + * color — hex integer (0xRRGGBB) used for Three.js materials and lights + * role — human-readable role string shown under the label sprite + * direction — cardinal facing direction (for future mesh orientation use) + * x, z — world-space position on the horizontal plane (y is always 0) + */ +export const AGENT_DEFS = [ + { id: 'timmy', label: 'TIMMY', color: 0x00ff41, role: 'sovereign agent', direction: 'north', x: 0, z: 0 }, + { id: 'perplexity', label: 'PERPLEXITY', color: 0x20b8cd, role: 'integration architect', direction: 'east', x: 5, z: 3 }, + { id: 'replit', label: 'REPLIT', color: 0xff6622, role: 'lead architect', direction: 'south', x: -5, z: 3 }, + { id: 'kimi', label: 'KIMI', color: 0xcc44ff, role: 'scout', direction: 'west', x: -5, z: -3 }, + { id: 'claude', label: 'CLAUDE', color: 0xd4a574, role: 'senior engineer', direction: 'north', x: 5, z: -3 }, +]; + +/** + * Convert an integer color (e.g. 0x00ff88) to a CSS hex string ('#00ff88'). + * Useful for DOM styling and canvas rendering. + */ +export function colorToCss(intColor) { + return '#' + intColor.toString(16).padStart(6, '0'); +} diff --git a/frontend/js/agents.js b/frontend/js/agents.js new file mode 100644 index 0000000..ceb157f --- /dev/null +++ b/frontend/js/agents.js @@ -0,0 +1,523 @@ +import * as THREE from 'three'; +import { AGENT_DEFS, colorToCss } from './agent-defs.js'; + +const agents = new Map(); +let scene; +let connectionLines = []; + +/* ── Shared geometries (created once, reused by all agents) ── */ +const SHARED_GEO = { + core: new THREE.IcosahedronGeometry(0.7, 1), + ring: new THREE.TorusGeometry(1.1, 0.04, 8, 32), + glow: new THREE.SphereGeometry(1.3, 16, 16), +}; + +/* ── Shared connection line material (one instance for all lines) ── */ +const CONNECTION_MAT = new THREE.LineBasicMaterial({ + color: 0x00aa44, + transparent: true, + opacity: 0.5, +}); + +/* ── Active-conversation highlight material ── */ +const ACTIVE_CONNECTION_MAT = new THREE.LineBasicMaterial({ + color: 0x00ff41, + transparent: true, + opacity: 0.9, +}); + +/** Map of active pulse timers: `${idA}-${idB}` → timeoutId */ +const pulseTimers = new Map(); + +class Agent { + constructor(def) { + this.id = def.id; + this.label = def.label; + this.color = def.color; + this.role = def.role; + this.position = new THREE.Vector3(def.x, 0, def.z); + this.homePosition = this.position.clone(); // remember spawn point + this.state = 'idle'; + this.walletHealth = 1.0; // 0.0–1.0, 1.0 = healthy (#15) + this.pulsePhase = Math.random() * Math.PI * 2; + + // Movement system + this._moveTarget = null; // THREE.Vector3 or null + this._moveSpeed = 2.0; // units/sec (adjustable per moveTo call) + this._moveCallback = null; // called when arrival reached + + // Stress glow color targets (#15) + this._baseColor = new THREE.Color(def.color); + this._stressColor = new THREE.Color(0xff4400); // amber-red for low health + this._currentGlowColor = new THREE.Color(def.color); + + this.group = new THREE.Group(); + this.group.position.copy(this.position); + + this._buildMeshes(); + this._buildLabel(); + } + + _buildMeshes() { + // Per-agent materials (need unique color + mutable emissiveIntensity) + const coreMat = new THREE.MeshStandardMaterial({ + color: this.color, + emissive: this.color, + emissiveIntensity: 0.4, + roughness: 0.3, + metalness: 0.8, + }); + + this.core = new THREE.Mesh(SHARED_GEO.core, coreMat); + this.group.add(this.core); + + const ringMat = new THREE.MeshBasicMaterial({ color: this.color, transparent: true, opacity: 0.5 }); + this.ring = new THREE.Mesh(SHARED_GEO.ring, ringMat); + this.ring.rotation.x = Math.PI / 2; + this.group.add(this.ring); + + const glowMat = new THREE.MeshBasicMaterial({ + color: this.color, + transparent: true, + opacity: 0.05, + side: THREE.BackSide, + }); + this.glow = new THREE.Mesh(SHARED_GEO.glow, glowMat); + this.group.add(this.glow); + + const light = new THREE.PointLight(this.color, 1.5, 10); + this.group.add(light); + this.light = light; + } + + _buildLabel() { + const canvas = document.createElement('canvas'); + canvas.width = 256; canvas.height = 64; + const ctx = canvas.getContext('2d'); + ctx.fillStyle = 'rgba(0,0,0,0)'; + ctx.fillRect(0, 0, 256, 64); + ctx.font = 'bold 22px Courier New'; + ctx.fillStyle = colorToCss(this.color); + ctx.textAlign = 'center'; + ctx.fillText(this.label, 128, 28); + ctx.font = '14px Courier New'; + ctx.fillStyle = '#007722'; + ctx.fillText(this.role.toUpperCase(), 128, 50); + + const tex = new THREE.CanvasTexture(canvas); + const spriteMat = new THREE.SpriteMaterial({ map: tex, transparent: true }); + this.sprite = new THREE.Sprite(spriteMat); + this.sprite.scale.set(2.4, 0.6, 1); + this.sprite.position.y = 2; + this.group.add(this.sprite); + } + + /** + * Move agent toward a target position over time. + * @param {THREE.Vector3|{x,z}} target — destination (y ignored, stays 0) + * @param {number} [speed=2.0] — units per second + * @param {Function} [onArrive] — callback when agent reaches target + */ + moveTo(target, speed = 2.0, onArrive = null) { + this._moveTarget = new THREE.Vector3( + target.x ?? target.getComponent?.(0) ?? 0, + 0, + target.z ?? target.getComponent?.(2) ?? 0 + ); + this._moveSpeed = speed; + this._moveCallback = onArrive; + } + + /** Cancel in-progress movement. */ + stopMoving() { + this._moveTarget = null; + this._moveCallback = null; + } + + /** @returns {boolean} true if agent is currently moving toward a target */ + get isMoving() { + return this._moveTarget !== null; + } + + update(time, delta) { + // ── Movement interpolation ── + if (this._moveTarget) { + const step = this._moveSpeed * delta; + const dist = this.position.distanceTo(this._moveTarget); + if (dist <= step + 0.05) { + // Arrived + this.position.copy(this._moveTarget); + this.position.y = 0; + this.group.position.x = this.position.x; + this.group.position.z = this.position.z; + const cb = this._moveCallback; + this._moveTarget = null; + this._moveCallback = null; + if (cb) cb(); + } else { + // Lerp toward target + const dir = new THREE.Vector3().subVectors(this._moveTarget, this.position).normalize(); + this.position.addScaledVector(dir, step); + this.position.y = 0; + this.group.position.x = this.position.x; + this.group.position.z = this.position.z; + } + } + + // ── Visual effects ── + const pulse = Math.sin(time * 0.002 + this.pulsePhase); + const active = this.state === 'active'; + const moving = this.isMoving; + const wh = this.walletHealth; + + // Budget stress glow (#15): blend base color toward stress color as wallet drops + const stressT = 1 - Math.max(0, Math.min(1, wh)); + this._currentGlowColor.copy(this._baseColor).lerp(this._stressColor, stressT * stressT); + + // Stress breathing: faster + wider pulse when wallet is low + const stressPulseSpeed = 0.002 + stressT * 0.006; + const stressPulse = Math.sin(time * stressPulseSpeed + this.pulsePhase); + const breathingAmp = stressT > 0.5 ? 0.15 + stressT * 0.15 : 0; + const stressBreathe = breathingAmp * stressPulse; + + const intensity = active ? 0.6 + pulse * 0.4 : 0.2 + pulse * 0.1 + stressBreathe; + this.core.material.emissiveIntensity = intensity; + this.core.material.emissive.copy(this._currentGlowColor); + this.light.color.copy(this._currentGlowColor); + this.light.intensity = active ? 2 + pulse : 0.8 + pulse * 0.3; + + // Glow sphere shows stress color + this.glow.material.color.copy(this._currentGlowColor); + this.glow.material.opacity = 0.05 + stressT * 0.08; + + const scale = active ? 1 + pulse * 0.08 : 1 + pulse * 0.03; + this.core.scale.setScalar(scale); + + // Ring spins faster when moving + this.ring.rotation.y += moving ? 0.05 : (active ? 0.03 : 0.008); + this.ring.material.opacity = 0.3 + pulse * 0.2; + + this.group.position.y = this.position.y + Math.sin(time * 0.001 + this.pulsePhase) * 0.15; + } + + setState(state) { + this.state = state; + } + + /** + * Set wallet health (0.0–1.0). Affects glow color and pulse. (#15) + */ + setWalletHealth(health) { + this.walletHealth = Math.max(0, Math.min(1, health)); + } + + /** + * Dispose per-agent GPU resources (materials + textures). + * Shared geometries are NOT disposed here — they outlive individual agents. + */ + dispose() { + this.core.material.dispose(); + this.ring.material.dispose(); + this.glow.material.dispose(); + this.sprite.material.map.dispose(); + this.sprite.material.dispose(); + } +} + +export function initAgents(sceneRef) { + scene = sceneRef; + + AGENT_DEFS.forEach(def => { + const agent = new Agent(def); + agents.set(def.id, agent); + scene.add(agent.group); + }); + + buildConnectionLines(); +} + +function buildConnectionLines() { + // Dispose old line geometries before removing + connectionLines.forEach(l => { + scene.remove(l); + l.geometry.dispose(); + // Material is shared — do NOT dispose here + }); + connectionLines = []; + + const agentList = [...agents.values()]; + + for (let i = 0; i < agentList.length; i++) { + for (let j = i + 1; j < agentList.length; j++) { + const a = agentList[i]; + const b = agentList[j]; + if (a.position.distanceTo(b.position) <= 14) { + const points = [a.position.clone(), b.position.clone()]; + const geo = new THREE.BufferGeometry().setFromPoints(points); + const line = new THREE.Line(geo, CONNECTION_MAT); + connectionLines.push(line); + scene.add(line); + } + } + } +} + +export function updateAgents(time, delta) { + agents.forEach(agent => agent.update(time, delta)); + // Update connection lines to follow agents as they move + updateConnectionLines(); +} + +/** Update connection line endpoints to track moving agents. */ +function updateConnectionLines() { + const agentList = [...agents.values()]; + let lineIdx = 0; + for (let i = 0; i < agentList.length; i++) { + for (let j = i + 1; j < agentList.length; j++) { + if (lineIdx >= connectionLines.length) return; + const a = agentList[i]; + const b = agentList[j]; + if (a.position.distanceTo(b.position) <= 20) { + const line = connectionLines[lineIdx]; + const pos = line.geometry.attributes.position; + pos.setXYZ(0, a.position.x, a.position.y, a.position.z); + pos.setXYZ(1, b.position.x, b.position.y, b.position.z); + pos.needsUpdate = true; + line.visible = true; + lineIdx++; + } + } + } + // Hide any excess lines (agents moved apart) + for (; lineIdx < connectionLines.length; lineIdx++) { + connectionLines[lineIdx].visible = false; + } +} + +/** + * Move an agent toward a position. Used by behavior system and WS commands. + * @param {string} agentId + * @param {{x: number, z: number}} target + * @param {number} [speed=2.0] + * @param {Function} [onArrive] + */ +export function moveAgentTo(agentId, target, speed = 2.0, onArrive = null) { + const agent = agents.get(agentId); + if (agent) agent.moveTo(target, speed, onArrive); +} + +/** Stop an agent's movement. */ +export function stopAgentMovement(agentId) { + const agent = agents.get(agentId); + if (agent) agent.stopMoving(); +} + +/** Check if an agent is currently in motion. */ +export function isAgentMoving(agentId) { + const agent = agents.get(agentId); + return agent ? agent.isMoving : false; +} + +export function getAgentCount() { + return agents.size; +} + +/** + * Temporarily highlight the connection line between two agents. + * Used during agent-to-agent conversations (interview, collaboration). + * + * @param {string} idA — first agent + * @param {string} idB — second agent + * @param {number} durationMs — how long to keep the line bright (default 4000) + */ +export function pulseConnection(idA, idB, durationMs = 4000) { + // Find the connection line between these two agents + const a = agents.get(idA); + const b = agents.get(idB); + if (!a || !b) return; + + const key = [idA, idB].sort().join('-'); + + // Find the line connecting them + for (const line of connectionLines) { + const pos = line.geometry.attributes.position; + if (!pos || pos.count < 2) continue; + const p0 = new THREE.Vector3(pos.getX(0), pos.getY(0), pos.getZ(0)); + const p1 = new THREE.Vector3(pos.getX(1), pos.getY(1), pos.getZ(1)); + + const matchesAB = (p0.distanceTo(a.position) < 0.5 && p1.distanceTo(b.position) < 0.5); + const matchesBA = (p0.distanceTo(b.position) < 0.5 && p1.distanceTo(a.position) < 0.5); + + if (matchesAB || matchesBA) { + // Swap to highlight material + line.material = ACTIVE_CONNECTION_MAT; + + // Clear any existing timer for this pair + if (pulseTimers.has(key)) { + clearTimeout(pulseTimers.get(key)); + } + + // Reset after duration + const timer = setTimeout(() => { + line.material = CONNECTION_MAT; + pulseTimers.delete(key); + }, durationMs); + pulseTimers.set(key, timer); + return; + } + } +} + +export function setAgentState(agentId, state) { + const agent = agents.get(agentId); + if (agent) agent.setState(state); +} + +/** + * Set wallet health for an agent (Issue #15). + * @param {string} agentId + * @param {number} health — 0.0 (broke) to 1.0 (full) + */ +export function setAgentWalletHealth(agentId, health) { + const agent = agents.get(agentId); + if (agent) agent.setWalletHealth(health); +} + +/** + * Get an agent's world position (for satflow particle targeting). + * @param {string} agentId + * @returns {THREE.Vector3|null} + */ +export function getAgentPosition(agentId) { + const agent = agents.get(agentId); + return agent ? agent.position.clone() : null; +} + +export function getAgentDefs() { + return [...agents.values()].map(a => ({ + id: a.id, label: a.label, role: a.role, color: a.color, state: a.state, + })); +} + +/** + * Dynamic agent hot-add (Issue #12). + * + * Spawns a new 3D agent at runtime when the backend sends an agent_joined event. + * If x/z are not provided, the agent is auto-placed in the next available slot + * on a circle around the origin (radius 8) to avoid overlapping existing agents. + * + * @param {object} def — Agent definition { id, label, color, role, direction, x, z } + * @returns {boolean} true if added, false if agent with that id already exists + */ +export function addAgent(def) { + if (agents.has(def.id)) { + console.warn('[Agents] Agent', def.id, 'already exists — skipping hot-add'); + return false; + } + + // Auto-place if no position given + if (def.x == null || def.z == null) { + const placed = autoPlace(); + def.x = placed.x; + def.z = placed.z; + } + + const agent = new Agent(def); + agents.set(def.id, agent); + scene.add(agent.group); + + // Rebuild connection lines to include the new agent + buildConnectionLines(); + + console.info('[Agents] Hot-added agent:', def.id, 'at', def.x, def.z); + return true; +} + +/** + * Find an unoccupied position on a circle around the origin. + * Tries radius 8 first (same ring as the original 4), then expands. + */ +function autoPlace() { + const existing = [...agents.values()].map(a => a.position); + const RADIUS_START = 8; + const RADIUS_STEP = 4; + const ANGLE_STEP = Math.PI / 6; // 30° increments = 12 slots per ring + const MIN_DISTANCE = 3; // minimum gap between agents + + for (let r = RADIUS_START; r <= RADIUS_START + RADIUS_STEP * 3; r += RADIUS_STEP) { + for (let angle = 0; angle < Math.PI * 2; angle += ANGLE_STEP) { + const x = Math.round(r * Math.sin(angle) * 10) / 10; + const z = Math.round(r * Math.cos(angle) * 10) / 10; + const candidate = new THREE.Vector3(x, 0, z); + const tooClose = existing.some(p => p.distanceTo(candidate) < MIN_DISTANCE); + if (!tooClose) { + return { x, z }; + } + } + } + + // Fallback: random offset if all slots taken (very unlikely) + return { x: (Math.random() - 0.5) * 20, z: (Math.random() - 0.5) * 20 }; +} + +/** + * Remove an agent from the scene and dispose its resources. + * Useful for agent_left events. + * + * @param {string} agentId + * @returns {boolean} true if removed + */ +export function removeAgent(agentId) { + const agent = agents.get(agentId); + if (!agent) return false; + + scene.remove(agent.group); + agent.dispose(); + agents.delete(agentId); + buildConnectionLines(); + + console.info('[Agents] Removed agent:', agentId); + return true; +} + +/** + * Snapshot current agent states for preservation across WebGL context loss. + * @returns {Object.} agentId → state string + */ +export function getAgentStates() { + const snapshot = {}; + for (const [id, agent] of agents) { + snapshot[id] = agent.state || 'idle'; + } + return snapshot; +} + +/** + * Reapply a state snapshot after world rebuild. + * @param {Object.} snapshot + */ +export function applyAgentStates(snapshot) { + if (!snapshot) return; + for (const [id, state] of Object.entries(snapshot)) { + const agent = agents.get(id); + if (agent) agent.state = state; + } +} + +/** + * Dispose all agent resources (used on world teardown). + */ +export function disposeAgents() { + // Dispose connection line geometries first + connectionLines.forEach(l => { + scene.remove(l); + l.geometry.dispose(); + }); + connectionLines = []; + + for (const [id, agent] of agents) { + scene.remove(agent.group); + agent.dispose(); + } + agents.clear(); +} diff --git a/frontend/js/ambient.js b/frontend/js/ambient.js new file mode 100644 index 0000000..d717e6e --- /dev/null +++ b/frontend/js/ambient.js @@ -0,0 +1,212 @@ +/** + * ambient.js — Mood-driven scene atmosphere. + * + * Timmy's mood (calm, focused, excited, contemplative, stressed) + * smoothly transitions the scene's lighting color temperature, + * fog density, rain intensity, and ambient sound cues. + * + * Resolves Issue #43 — Ambient state system + */ +import * as THREE from 'three'; + +/* ── Mood definitions ── */ + +const MOODS = { + calm: { + fogDensity: 0.035, + fogColor: new THREE.Color(0x000000), + ambientColor: new THREE.Color(0x001a00), + ambientIntensity: 0.6, + pointColor: new THREE.Color(0x00ff41), + pointIntensity: 2, + rainSpeed: 1.0, + rainOpacity: 0.7, + starOpacity: 0.5, + }, + focused: { + fogDensity: 0.025, + fogColor: new THREE.Color(0x000500), + ambientColor: new THREE.Color(0x002200), + ambientIntensity: 0.8, + pointColor: new THREE.Color(0x00ff88), + pointIntensity: 2.5, + rainSpeed: 0.7, + rainOpacity: 0.5, + starOpacity: 0.6, + }, + excited: { + fogDensity: 0.02, + fogColor: new THREE.Color(0x050500), + ambientColor: new THREE.Color(0x1a1a00), + ambientIntensity: 1.0, + pointColor: new THREE.Color(0x44ff44), + pointIntensity: 3.5, + rainSpeed: 1.8, + rainOpacity: 0.9, + starOpacity: 0.8, + }, + contemplative: { + fogDensity: 0.05, + fogColor: new THREE.Color(0x000005), + ambientColor: new THREE.Color(0x000a1a), + ambientIntensity: 0.4, + pointColor: new THREE.Color(0x2288cc), + pointIntensity: 1.5, + rainSpeed: 0.4, + rainOpacity: 0.4, + starOpacity: 0.7, + }, + stressed: { + fogDensity: 0.015, + fogColor: new THREE.Color(0x050000), + ambientColor: new THREE.Color(0x1a0500), + ambientIntensity: 0.5, + pointColor: new THREE.Color(0xff4422), + pointIntensity: 3.0, + rainSpeed: 2.5, + rainOpacity: 1.0, + starOpacity: 0.3, + }, +}; + +/* ── State ── */ + +let scene = null; +let ambientLt = null; +let pointLt = null; + +let currentMood = 'calm'; +let targetMood = 'calm'; +let blendT = 1.0; // 0→1, 1 = fully at target +const BLEND_SPEED = 0.4; // units per second — smooth ~2.5s transition + +// Snapshot of the "from" state when a transition starts +let fromState = null; + +/* ── External handles for effects.js integration ── */ +let _rainSpeedMul = 1.0; +let _rainOpacity = 0.7; +let _starOpacity = 0.5; + +export function getRainSpeedMultiplier() { return _rainSpeedMul; } +export function getRainOpacity() { return _rainOpacity; } +export function getStarOpacity() { return _starOpacity; } + +/* ── API ── */ + +/** + * Bind ambient system to the scene's lights. + * Must be called after initWorld() creates the scene. + */ +export function initAmbient(scn) { + scene = scn; + // Find the ambient and point lights created by world.js + scene.traverse(obj => { + if (obj.isAmbientLight && !ambientLt) ambientLt = obj; + if (obj.isPointLight && !pointLt) pointLt = obj; + }); + // Initialize from calm state + _applyMood(MOODS.calm, 1); +} + +/** + * Set the mood, triggering a smooth transition. + * @param {string} mood — one of: calm, focused, excited, contemplative, stressed + */ +export function setAmbientState(mood) { + if (!MOODS[mood] || mood === targetMood) return; + + // Snapshot current interpolated state as the "from" + fromState = _snapshot(); + currentMood = targetMood; + targetMood = mood; + blendT = 0; +} + +/** Get the current mood label. */ +export function getAmbientMood() { + return blendT >= 1 ? targetMood : `${currentMood}→${targetMood}`; +} + +/** + * Per-frame update — call from the render loop. + * @param {number} delta — seconds since last frame + */ +export function updateAmbient(delta) { + if (blendT >= 1) return; // nothing to interpolate + + blendT = Math.min(1, blendT + BLEND_SPEED * delta); + const t = _ease(blendT); + const target = MOODS[targetMood] || MOODS.calm; + + if (fromState) { + _interpolate(fromState, target, t); + } + + if (blendT >= 1) { + fromState = null; // transition complete + } +} + +/** Dispose ambient state. */ +export function disposeAmbient() { + scene = null; + ambientLt = null; + pointLt = null; + fromState = null; + blendT = 1; + currentMood = 'calm'; + targetMood = 'calm'; +} + +/* ── Internals ── */ + +function _ease(t) { + // Smooth ease-in-out + return t < 0.5 + ? 2 * t * t + : 1 - Math.pow(-2 * t + 2, 2) / 2; +} + +function _snapshot() { + return { + fogDensity: scene?.fog?.density ?? 0.035, + fogColor: scene?.fog?.color?.clone() ?? new THREE.Color(0x000000), + ambientColor: ambientLt?.color?.clone() ?? new THREE.Color(0x001a00), + ambientIntensity: ambientLt?.intensity ?? 0.6, + pointColor: pointLt?.color?.clone() ?? new THREE.Color(0x00ff41), + pointIntensity: pointLt?.intensity ?? 2, + rainSpeed: _rainSpeedMul, + rainOpacity: _rainOpacity, + starOpacity: _starOpacity, + }; +} + +function _interpolate(from, to, t) { + // Fog + if (scene?.fog) { + scene.fog.density = THREE.MathUtils.lerp(from.fogDensity, to.fogDensity, t); + scene.fog.color.copy(from.fogColor).lerp(to.fogColor, t); + } + + // Ambient light + if (ambientLt) { + ambientLt.color.copy(from.ambientColor).lerp(to.ambientColor, t); + ambientLt.intensity = THREE.MathUtils.lerp(from.ambientIntensity, to.ambientIntensity, t); + } + + // Point light + if (pointLt) { + pointLt.color.copy(from.pointColor).lerp(to.pointColor, t); + pointLt.intensity = THREE.MathUtils.lerp(from.pointIntensity, to.pointIntensity, t); + } + + // Rain / star params (consumed by effects.js) + _rainSpeedMul = THREE.MathUtils.lerp(from.rainSpeed, to.rainSpeed, t); + _rainOpacity = THREE.MathUtils.lerp(from.rainOpacity, to.rainOpacity, t); + _starOpacity = THREE.MathUtils.lerp(from.starOpacity, to.starOpacity, t); +} + +function _applyMood(mood, t) { + _interpolate(mood, mood, t); // apply directly +} diff --git a/frontend/js/avatar.js b/frontend/js/avatar.js new file mode 100644 index 0000000..748c2e2 --- /dev/null +++ b/frontend/js/avatar.js @@ -0,0 +1,360 @@ +/** + * avatar.js — Visitor avatar with FPS movement and PiP dual-camera. + * + * Exports: + * initAvatar(scene, camera, renderer) — create avatar + PiP, bind input + * updateAvatar(delta) — move avatar, sync FP camera + * getAvatarMainCamera() — returns the camera for the current main view + * renderAvatarPiP(scene) — render the PiP after main render + * disposeAvatar() — cleanup everything + * getAvatarPosition() — { x, z, yaw } for presence messages + */ +import * as THREE from 'three'; + +const MOVE_SPEED = 8; +const TURN_SPEED = 0.003; +const EYE_HEIGHT = 2.2; +const AVATAR_COLOR = 0x00ffaa; +const WORLD_BOUNDS = 45; + +// Module state +let scene, orbitCamera, renderer; +let group, fpCamera; +let pipCanvas, pipRenderer, pipLabel; +let activeView = 'third'; // 'first' or 'third' for main viewport +let yaw = 0; // face -Z toward center + +// Input state +const keys = {}; +let isMouseLooking = false; +let touchId = null; +let touchStartX = 0, touchStartY = 0; +let touchDeltaX = 0, touchDeltaY = 0; + +// Bound handlers (for removal on dispose) +let _onKeyDown, _onKeyUp, _onMouseDown, _onMouseUp, _onMouseMove, _onContextMenu; +let _onTouchStart, _onTouchMove, _onTouchEnd; +let abortController; + +// ── Public API ── + +export function initAvatar(_scene, _orbitCamera, _renderer) { + scene = _scene; + orbitCamera = _orbitCamera; + renderer = _renderer; + activeView = 'third'; + yaw = 0; + + abortController = new AbortController(); + const signal = abortController.signal; + + _buildAvatar(); + _buildFPCamera(); + _buildPiP(); + _bindInput(signal); +} + +export function updateAvatar(delta) { + if (!group) return; + if (document.activeElement?.tagName === 'INPUT' || + document.activeElement?.tagName === 'TEXTAREA') return; + + let mx = 0, mz = 0; + if (keys['w']) mz += 1; + if (keys['s']) mz -= 1; + if (keys['a']) mx -= 1; + if (keys['d']) mx += 1; + if (keys['ArrowUp']) mz += 1; + if (keys['ArrowDown']) mz -= 1; + // ArrowLeft/Right only turn (handled below) + + mx += touchDeltaX; + mz -= touchDeltaY; + + if (keys['ArrowLeft']) yaw += 1.5 * delta; + if (keys['ArrowRight']) yaw -= 1.5 * delta; + + if (mx !== 0 || mz !== 0) { + const len = Math.sqrt(mx * mx + mz * mz); + mx /= len; + mz /= len; + const speed = MOVE_SPEED * delta; + // Forward = -Z at yaw=0 (Three.js default) + const fwdX = -Math.sin(yaw); + const fwdZ = -Math.cos(yaw); + const rightX = Math.cos(yaw); + const rightZ = -Math.sin(yaw); + group.position.x += (mx * rightX + mz * fwdX) * speed; + group.position.z += (mx * rightZ + mz * fwdZ) * speed; + } + + // Clamp to world bounds + group.position.x = Math.max(-WORLD_BOUNDS, Math.min(WORLD_BOUNDS, group.position.x)); + group.position.z = Math.max(-WORLD_BOUNDS, Math.min(WORLD_BOUNDS, group.position.z)); + + // Avatar rotation + group.rotation.y = yaw; + + // FP camera follows avatar head + fpCamera.position.set( + group.position.x, + group.position.y + EYE_HEIGHT, + group.position.z, + ); + fpCamera.rotation.set(0, yaw, 0, 'YXZ'); +} + +export function getAvatarMainCamera() { + return activeView === 'first' ? fpCamera : orbitCamera; +} + +export function renderAvatarPiP(_scene) { + if (!pipRenderer || !_scene) return; + const cam = activeView === 'third' ? fpCamera : orbitCamera; + pipRenderer.render(_scene, cam); +} + +export function getAvatarPosition() { + if (!group) return { x: 0, z: 0, yaw: 0 }; + return { + x: Math.round(group.position.x * 10) / 10, + z: Math.round(group.position.z * 10) / 10, + yaw: Math.round(yaw * 100) / 100, + }; +} + +export function disposeAvatar() { + if (abortController) abortController.abort(); + + if (group) { + group.traverse(child => { + if (child.geometry) child.geometry.dispose(); + if (child.material) { + if (child.material.map) child.material.map.dispose(); + child.material.dispose(); + } + }); + scene?.remove(group); + group = null; + } + + if (pipRenderer) { pipRenderer.dispose(); pipRenderer = null; } + pipCanvas?.remove(); + pipLabel?.remove(); + pipCanvas = null; + pipLabel = null; +} + +// ── Internal builders ── + +function _buildAvatar() { + group = new THREE.Group(); + const mat = new THREE.MeshBasicMaterial({ + color: AVATAR_COLOR, + wireframe: true, + transparent: true, + opacity: 0.85, + }); + + // Head — icosahedron + const head = new THREE.Mesh(new THREE.IcosahedronGeometry(0.35, 1), mat); + head.position.y = 3.0; + group.add(head); + + // Torso + const torso = new THREE.Mesh(new THREE.BoxGeometry(0.7, 1.2, 0.4), mat); + torso.position.y = 1.9; + group.add(torso); + + // Legs + for (const x of [-0.2, 0.2]) { + const leg = new THREE.Mesh(new THREE.BoxGeometry(0.2, 1.1, 0.2), mat); + leg.position.set(x, 0.65, 0); + group.add(leg); + } + + // Arms + for (const x of [-0.55, 0.55]) { + const arm = new THREE.Mesh(new THREE.BoxGeometry(0.18, 1.0, 0.18), mat); + arm.position.set(x, 1.9, 0); + group.add(arm); + } + + // Glow + const glow = new THREE.PointLight(AVATAR_COLOR, 0.8, 8); + glow.position.y = 3.0; + group.add(glow); + + // Label + const canvas = document.createElement('canvas'); + canvas.width = 256; + canvas.height = 64; + const ctx = canvas.getContext('2d'); + ctx.font = '600 28px "Courier New", monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = '#00ffaa'; + ctx.shadowColor = '#00ffaa'; + ctx.shadowBlur = 12; + ctx.fillText('YOU', 128, 32); + + const tex = new THREE.CanvasTexture(canvas); + const spriteMat = new THREE.SpriteMaterial({ map: tex, transparent: true, depthWrite: false }); + const sprite = new THREE.Sprite(spriteMat); + sprite.scale.set(4, 1, 1); + sprite.position.y = 3.8; + group.add(sprite); + + // Spawn at world edge facing center + group.position.set(0, 0, 22); + scene.add(group); +} + +function _buildFPCamera() { + fpCamera = new THREE.PerspectiveCamera( + 70, + window.innerWidth / window.innerHeight, + 0.1, 500, + ); + window.addEventListener('resize', () => { + fpCamera.aspect = window.innerWidth / window.innerHeight; + fpCamera.updateProjectionMatrix(); + }); +} + +function _buildPiP() { + const W = 220, H = 150; + + pipCanvas = document.createElement('canvas'); + pipCanvas.id = 'pip-viewport'; + pipCanvas.width = W * Math.min(window.devicePixelRatio, 2); + pipCanvas.height = H * Math.min(window.devicePixelRatio, 2); + Object.assign(pipCanvas.style, { + position: 'fixed', + bottom: '16px', + right: '16px', + width: W + 'px', + height: H + 'px', + border: '1px solid rgba(0,255,65,0.5)', + borderRadius: '4px', + cursor: 'pointer', + zIndex: '100', + boxShadow: '0 0 20px rgba(0,255,65,0.15), inset 0 0 20px rgba(0,0,0,0.5)', + }); + document.body.appendChild(pipCanvas); + + pipRenderer = new THREE.WebGLRenderer({ canvas: pipCanvas, antialias: false }); + pipRenderer.setSize(W, H); + pipRenderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + + // Label + pipLabel = document.createElement('div'); + pipLabel.id = 'pip-label'; + Object.assign(pipLabel.style, { + position: 'fixed', + bottom: (16 + H + 4) + 'px', + right: '16px', + color: 'rgba(0,255,65,0.6)', + fontFamily: '"Courier New", monospace', + fontSize: '10px', + fontWeight: '500', + letterSpacing: '2px', + zIndex: '100', + pointerEvents: 'none', + }); + _updatePipLabel(); + document.body.appendChild(pipLabel); + + // Swap on click/tap + pipCanvas.addEventListener('click', _swapViews); + pipCanvas.addEventListener('touchstart', (e) => { + e.preventDefault(); + e.stopPropagation(); + _swapViews(); + }, { passive: false }); +} + +function _updatePipLabel() { + if (pipLabel) { + pipLabel.textContent = activeView === 'third' ? '◉ 1ST PERSON' : '◉ 3RD PERSON'; + } +} + +function _swapViews() { + activeView = activeView === 'third' ? 'first' : 'third'; + _updatePipLabel(); + if (group) group.visible = activeView === 'third'; +} + +// ── Input ── + +function _bindInput(signal) { + _onKeyDown = (e) => { + const k = e.key.length === 1 ? e.key.toLowerCase() : e.key; + keys[k] = true; + if (document.activeElement?.tagName === 'INPUT' || + document.activeElement?.tagName === 'TEXTAREA') return; + if (['w','a','s','d','ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(k)) { + e.preventDefault(); + } + }; + + _onKeyUp = (e) => { + const k = e.key.length === 1 ? e.key.toLowerCase() : e.key; + keys[k] = false; + }; + + _onMouseDown = (e) => { + if (e.button === 2) { isMouseLooking = true; e.preventDefault(); } + }; + + _onMouseUp = () => { isMouseLooking = false; }; + + _onMouseMove = (e) => { + if (!isMouseLooking) return; + yaw -= e.movementX * TURN_SPEED; + }; + + _onContextMenu = (e) => e.preventDefault(); + + _onTouchStart = (e) => { + for (const t of e.changedTouches) { + if (t.clientX < window.innerWidth * 0.5 && touchId === null) { + touchId = t.identifier; + touchStartX = t.clientX; + touchStartY = t.clientY; + touchDeltaX = 0; + touchDeltaY = 0; + } + } + }; + + _onTouchMove = (e) => { + for (const t of e.changedTouches) { + if (t.identifier === touchId) { + touchDeltaX = Math.max(-1, Math.min(1, (t.clientX - touchStartX) / 60)); + touchDeltaY = Math.max(-1, Math.min(1, (t.clientY - touchStartY) / 60)); + } + } + }; + + _onTouchEnd = (e) => { + for (const t of e.changedTouches) { + if (t.identifier === touchId) { + touchId = null; + touchDeltaX = 0; + touchDeltaY = 0; + } + } + }; + + document.addEventListener('keydown', _onKeyDown, { signal }); + document.addEventListener('keyup', _onKeyUp, { signal }); + renderer.domElement.addEventListener('mousedown', _onMouseDown, { signal }); + document.addEventListener('mouseup', _onMouseUp, { signal }); + renderer.domElement.addEventListener('mousemove', _onMouseMove, { signal }); + renderer.domElement.addEventListener('contextmenu', _onContextMenu, { signal }); + renderer.domElement.addEventListener('touchstart', _onTouchStart, { passive: true, signal }); + renderer.domElement.addEventListener('touchmove', _onTouchMove, { passive: true, signal }); + renderer.domElement.addEventListener('touchend', _onTouchEnd, { passive: true, signal }); +} diff --git a/frontend/js/bark.js b/frontend/js/bark.js new file mode 100644 index 0000000..0d44f0f --- /dev/null +++ b/frontend/js/bark.js @@ -0,0 +1,141 @@ +/** + * bark.js — Bark display system for the Workshop. + * + * Handles incoming bark messages from Timmy and displays them + * prominently in the viewport with typing animation and auto-dismiss. + * + * Resolves Issue #42 — Bark display system + */ + +import { appendChatMessage } from './ui.js'; +import { colorToCss, AGENT_DEFS } from './agent-defs.js'; + +const $container = document.getElementById('bark-container'); + +const BARK_DISPLAY_MS = 7000; // How long a bark stays visible +const BARK_FADE_MS = 600; // Fade-out animation duration +const BARK_TYPE_MS = 30; // Ms per character for typing effect +const MAX_BARKS = 3; // Max simultaneous barks on screen + +const barkQueue = []; +let activeBarkCount = 0; + +/** + * Display a bark in the viewport. + * + * @param {object} opts + * @param {string} opts.text — The bark text + * @param {string} [opts.agentId='timmy'] — Which agent is barking + * @param {string} [opts.emotion='calm'] — Emotion tag (calm, excited, uncertain) + * @param {string} [opts.color] — Override CSS color + */ +export function showBark({ text, agentId = 'timmy', emotion = 'calm', color }) { + if (!text || !$container) return; + + // Queue if too many active barks + if (activeBarkCount >= MAX_BARKS) { + barkQueue.push({ text, agentId, emotion, color }); + return; + } + + activeBarkCount++; + + // Resolve agent color + const agentDef = AGENT_DEFS.find(d => d.id === agentId); + const barkColor = color || (agentDef ? colorToCss(agentDef.color) : '#00ff41'); + const agentLabel = agentDef ? agentDef.label : agentId.toUpperCase(); + + // Create bark element + const el = document.createElement('div'); + el.className = `bark ${emotion}`; + el.style.borderLeftColor = barkColor; + el.innerHTML = `
${escapeHtml(agentLabel)}
`; + $container.appendChild(el); + + // Typing animation + const $text = el.querySelector('.bark-text'); + let charIndex = 0; + const typeInterval = setInterval(() => { + if (charIndex < text.length) { + $text.textContent += text[charIndex]; + charIndex++; + } else { + clearInterval(typeInterval); + } + }, BARK_TYPE_MS); + + // Also log to chat panel as permanent record + appendChatMessage(agentLabel, text, barkColor); + + // Auto-dismiss after display time + const displayTime = BARK_DISPLAY_MS + (text.length * BARK_TYPE_MS); + setTimeout(() => { + clearInterval(typeInterval); + el.classList.add('fade-out'); + setTimeout(() => { + el.remove(); + activeBarkCount--; + drainQueue(); + }, BARK_FADE_MS); + }, displayTime); +} + +/** + * Process queued barks when a slot opens. + */ +function drainQueue() { + if (barkQueue.length > 0 && activeBarkCount < MAX_BARKS) { + const next = barkQueue.shift(); + showBark(next); + } +} + +/** + * Escape HTML for safe text insertion. + */ +function escapeHtml(str) { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +// ── Mock barks for demo mode ── + +const DEMO_BARKS = [ + { text: 'The Tower watches. The Tower remembers.', emotion: 'calm' }, + { text: 'A visitor. Welcome to the Workshop.', emotion: 'calm' }, + { text: 'New commit on main. The code evolves.', emotion: 'excited' }, + { text: '222 — the number echoes again.', emotion: 'calm' }, + { text: 'I sense activity in the repo. Someone is building.', emotion: 'focused' }, + { text: 'The chain beats on. Block after block.', emotion: 'contemplative' }, + { text: 'Late night session? I know the pattern.', emotion: 'calm' }, + { text: 'Sovereignty means running your own mind.', emotion: 'calm' }, +]; + +let demoTimer = null; + +/** + * Start periodic demo barks (for mock mode). + */ +export function startDemoBarks() { + if (demoTimer) return; + // First bark after 5s, then every 15-25s + demoTimer = setTimeout(function nextBark() { + const bark = DEMO_BARKS[Math.floor(Math.random() * DEMO_BARKS.length)]; + showBark({ text: bark.text, agentId: 'alpha', emotion: bark.emotion }); + demoTimer = setTimeout(nextBark, 15000 + Math.random() * 10000); + }, 5000); +} + +/** + * Stop demo barks. + */ +export function stopDemoBarks() { + if (demoTimer) { + clearTimeout(demoTimer); + demoTimer = null; + } +} diff --git a/frontend/js/behaviors.js b/frontend/js/behaviors.js new file mode 100644 index 0000000..a09db76 --- /dev/null +++ b/frontend/js/behaviors.js @@ -0,0 +1,413 @@ +/** + * behaviors.js — Autonomous agent behavior system. + * + * Makes agents proactively alive: wandering, pondering, inspecting scene + * objects, conversing with each other, and placing small artifacts. + * + * Client-side default layer. When a real backend connects via WS, it can + * override behaviors with `agent_behavior` messages. The autonomous loop + * yields to server-driven behaviors and resumes when they complete. + * + * Follows the Pip familiar pattern (src/timmy/familiar.py): + * - State machine picks behavior + target position + * - Movement system (agents.js) handles interpolation + * - Visual systems (agents.js, bark.js) handle rendering + * + * Issue #68 + */ + +import { AGENT_DEFS } from './agent-defs.js'; +import { + moveAgentTo, stopAgentMovement, isAgentMoving, + setAgentState, getAgentPosition, pulseConnection, +} from './agents.js'; +import { showBark } from './bark.js'; +import { getSceneObjectCount, addSceneObject } from './scene-objects.js'; + +/* ── Constants ── */ + +const WORLD_RADIUS = 15; // max wander distance from origin +const HOME_RADIUS = 3; // "close to home" threshold +const APPROACH_DISTANCE = 2.5; // how close agents get to each other +const MIN_DECISION_INTERVAL = 0.5; // seconds between behavior ticks (saves CPU) + +/* ── Behavior definitions ── */ + +/** + * @typedef {'idle'|'wander'|'ponder'|'inspect'|'converse'|'place'|'return_home'} BehaviorType + */ + +/** Duration ranges in seconds [min, max] */ +const DURATIONS = { + idle: [5, 15], + wander: [8, 20], + ponder: [6, 12], + inspect: [4, 8], + converse: [8, 15], + place: [3, 6], + return_home: [0, 0], // ends when agent arrives +}; + +/** Agent personality weights — higher = more likely to choose that behavior. + * Each agent gets a distinct personality. */ +const PERSONALITIES = { + timmy: { idle: 1, wander: 3, ponder: 5, inspect: 2, converse: 3, place: 2 }, + perplexity: { idle: 2, wander: 3, ponder: 2, inspect: 4, converse: 3, place: 1 }, + replit: { idle: 1, wander: 4, ponder: 1, inspect: 2, converse: 2, place: 4 }, + kimi: { idle: 2, wander: 3, ponder: 3, inspect: 5, converse: 2, place: 1 }, + claude: { idle: 2, wander: 2, ponder: 3, inspect: 2, converse: 5, place: 1 }, +}; + +const DEFAULT_PERSONALITY = { idle: 2, wander: 3, ponder: 2, inspect: 2, converse: 3, place: 1 }; + +/* ── Bark lines per behavior ── */ + +const PONDER_BARKS = [ + { text: 'The code reveals its patterns...', emotion: 'contemplative' }, + { text: 'What if we approached it differently?', emotion: 'curious' }, + { text: 'I see the shape of a solution forming.', emotion: 'focused' }, + { text: 'The architecture wants to be simpler.', emotion: 'calm' }, + { text: 'Something here deserves deeper thought.', emotion: 'contemplative' }, + { text: 'Every constraint is a design decision.', emotion: 'focused' }, +]; + +const CONVERSE_BARKS = [ + { text: 'Have you noticed the pattern in the recent commits?', emotion: 'curious' }, + { text: 'I think we should refactor this together.', emotion: 'focused' }, + { text: 'Your approach to that problem was interesting.', emotion: 'calm' }, + { text: 'Let me share what I found.', emotion: 'excited' }, + { text: 'We should coordinate on the next sprint.', emotion: 'focused' }, +]; + +const INSPECT_BARKS = [ + { text: 'This artifact holds memory...', emotion: 'contemplative' }, + { text: 'Interesting construction.', emotion: 'curious' }, + { text: 'The world grows richer.', emotion: 'calm' }, +]; + +const PLACE_BARKS = [ + { text: 'A marker for what I learned.', emotion: 'calm' }, + { text: 'Building the world, one piece at a time.', emotion: 'focused' }, + { text: 'This belongs here.', emotion: 'contemplative' }, +]; + +/* ── Artifact templates for place behavior ── */ + +const ARTIFACT_TEMPLATES = [ + { geometry: 'icosahedron', scale: { x: 0.3, y: 0.3, z: 0.3 }, material: { type: 'standard', metalness: 0.8, roughness: 0.2 }, animation: [{ type: 'rotate', y: 0.5 }, { type: 'bob', amplitude: 0.1, speed: 1 }] }, + { geometry: 'octahedron', scale: { x: 0.25, y: 0.25, z: 0.25 }, material: { type: 'standard', metalness: 0.6, roughness: 0.3 }, animation: [{ type: 'rotate', y: -0.3 }] }, + { geometry: 'torus', scale: { x: 0.3, y: 0.3, z: 0.3 }, material: { type: 'standard', metalness: 0.7, roughness: 0.2 }, animation: [{ type: 'rotate', x: 0.4, y: 0.6 }] }, + { geometry: 'tetrahedron', scale: { x: 0.3, y: 0.3, z: 0.3 }, material: { type: 'phong', shininess: 80 }, animation: [{ type: 'bob', amplitude: 0.15, speed: 0.8 }] }, + { geometry: 'sphere', radius: 0.15, material: { type: 'physical', metalness: 0.9, roughness: 0.1, emissive: null, emissiveIntensity: 0.3 }, animation: [{ type: 'pulse', min: 0.9, max: 1.1, speed: 2 }] }, +]; + +/* ── Per-agent behavior state ── */ + +class AgentBehavior { + constructor(agentId) { + this.agentId = agentId; + this.personality = PERSONALITIES[agentId] || DEFAULT_PERSONALITY; + this.currentBehavior = 'idle'; + this.behaviorTimer = 0; // seconds remaining in current behavior + this.conversePeer = null; // agentId of converse partner + this._wsOverride = false; // true when backend is driving behavior + this._wsOverrideTimer = 0; + this._artifactCount = 0; // prevent artifact spam + } + + /** Pick next behavior using weighted random selection. */ + pickNextBehavior(allBehaviors) { + const candidates = Object.entries(this.personality); + const totalWeight = candidates.reduce((sum, [, w]) => sum + w, 0); + let roll = Math.random() * totalWeight; + + for (const [behavior, weight] of candidates) { + roll -= weight; + if (roll <= 0) { + // Converse requires a free partner + if (behavior === 'converse') { + const peer = this._findConversePeer(allBehaviors); + if (!peer) return 'wander'; // no free partner, wander instead + this.conversePeer = peer; + const peerBehavior = allBehaviors.get(peer); + if (peerBehavior) { + peerBehavior.currentBehavior = 'converse'; + peerBehavior.conversePeer = this.agentId; + peerBehavior.behaviorTimer = randRange(...DURATIONS.converse); + } + } + // Place requires scene object count under limit + if (behavior === 'place' && (getSceneObjectCount() >= 180 || this._artifactCount >= 5)) { + return 'ponder'; // too many objects, ponder instead + } + return behavior; + } + } + return 'idle'; + } + + /** Find another agent that's idle or wandering (available to converse). */ + _findConversePeer(allBehaviors) { + const candidates = []; + for (const [id, b] of allBehaviors) { + if (id === this.agentId) continue; + if (b.currentBehavior === 'idle' || b.currentBehavior === 'wander') { + candidates.push(id); + } + } + return candidates.length > 0 ? candidates[Math.floor(Math.random() * candidates.length)] : null; + } +} + +/* ── Module state ── */ + +/** @type {Map} */ +const behaviors = new Map(); +let initialized = false; +let decisionAccumulator = 0; + +/* ── Utility ── */ + +function randRange(min, max) { + return min + Math.random() * (max - min); +} + +function pick(arr) { + return arr[Math.floor(Math.random() * arr.length)]; +} + +function randomWorldPoint(maxRadius = WORLD_RADIUS) { + const angle = Math.random() * Math.PI * 2; + const r = Math.sqrt(Math.random()) * maxRadius; // sqrt for uniform distribution + return { x: Math.cos(angle) * r, z: Math.sin(angle) * r }; +} + +function colorIntToHex(intColor) { + return '#' + intColor.toString(16).padStart(6, '0'); +} + +/* ── Behavior executors ── */ + +function executeIdle(ab) { + setAgentState(ab.agentId, 'idle'); + stopAgentMovement(ab.agentId); +} + +function executeWander(ab) { + setAgentState(ab.agentId, 'active'); + const target = randomWorldPoint(WORLD_RADIUS); + moveAgentTo(ab.agentId, target, 1.5 + Math.random() * 1.0); +} + +function executePonder(ab) { + setAgentState(ab.agentId, 'active'); + stopAgentMovement(ab.agentId); + // Bark a thought + const bark = pick(PONDER_BARKS); + showBark({ text: bark.text, agentId: ab.agentId, emotion: bark.emotion }); +} + +function executeInspect(ab) { + setAgentState(ab.agentId, 'active'); + // Move to a random point nearby (simulating "looking at something") + const pos = getAgentPosition(ab.agentId); + if (pos) { + const target = { + x: pos.x + (Math.random() - 0.5) * 6, + z: pos.z + (Math.random() - 0.5) * 6, + }; + moveAgentTo(ab.agentId, target, 1.0, () => { + const bark = pick(INSPECT_BARKS); + showBark({ text: bark.text, agentId: ab.agentId, emotion: bark.emotion }); + }); + } +} + +function executeConverse(ab) { + if (!ab.conversePeer) return; + setAgentState(ab.agentId, 'active'); + const peerPos = getAgentPosition(ab.conversePeer); + if (peerPos) { + const myPos = getAgentPosition(ab.agentId); + if (myPos) { + // Move toward peer but stop short + const dx = peerPos.x - myPos.x; + const dz = peerPos.z - myPos.z; + const dist = Math.sqrt(dx * dx + dz * dz); + if (dist > APPROACH_DISTANCE) { + const ratio = (dist - APPROACH_DISTANCE) / dist; + const target = { x: myPos.x + dx * ratio, z: myPos.z + dz * ratio }; + moveAgentTo(ab.agentId, target, 2.0, () => { + pulseConnection(ab.agentId, ab.conversePeer, 6000); + const bark = pick(CONVERSE_BARKS); + showBark({ text: bark.text, agentId: ab.agentId, emotion: bark.emotion }); + }); + } else { + pulseConnection(ab.agentId, ab.conversePeer, 6000); + const bark = pick(CONVERSE_BARKS); + showBark({ text: bark.text, agentId: ab.agentId, emotion: bark.emotion }); + } + } + } +} + +function executePlace(ab) { + setAgentState(ab.agentId, 'active'); + const pos = getAgentPosition(ab.agentId); + if (!pos) return; + + const template = pick(ARTIFACT_TEMPLATES); + const agentDef = AGENT_DEFS.find(d => d.id === ab.agentId); + const color = agentDef ? colorIntToHex(agentDef.color) : '#00ff41'; + + // Place artifact near current position + const artPos = { + x: pos.x + (Math.random() - 0.5) * 3, + y: 0.5 + Math.random() * 0.5, + z: pos.z + (Math.random() - 0.5) * 3, + }; + + const material = { ...template.material, color }; + if (material.emissive === null) material.emissive = color; + + const artifactId = `artifact_${ab.agentId}_${Date.now()}`; + addSceneObject({ + id: artifactId, + geometry: template.geometry, + position: artPos, + scale: template.scale || undefined, + radius: template.radius || undefined, + material, + animation: template.animation, + }); + + ab._artifactCount++; + + const bark = pick(PLACE_BARKS); + showBark({ text: bark.text, agentId: ab.agentId, emotion: bark.emotion }); +} + +function executeReturnHome(ab) { + setAgentState(ab.agentId, 'idle'); + const homeDef = AGENT_DEFS.find(d => d.id === ab.agentId); + if (homeDef) { + moveAgentTo(ab.agentId, { x: homeDef.x, z: homeDef.z }, 2.0); + } +} + +const EXECUTORS = { + idle: executeIdle, + wander: executeWander, + ponder: executePonder, + inspect: executeInspect, + converse: executeConverse, + place: executePlace, + return_home: executeReturnHome, +}; + +/* ── WS override listener ── */ + +function onBehaviorOverride(e) { + const msg = e.detail; + const ab = behaviors.get(msg.agentId); + if (!ab) return; + + ab._wsOverride = true; + ab._wsOverrideTimer = msg.duration || 10; + ab.currentBehavior = msg.behavior; + ab.behaviorTimer = msg.duration || 10; + + // Execute the override behavior + if (msg.target) { + moveAgentTo(msg.agentId, msg.target, msg.speed || 2.0); + } + const executor = EXECUTORS[msg.behavior]; + if (executor && !msg.target) executor(ab); +} + +/* ── Public API ── */ + +/** + * Initialize the behavior system. Call after initAgents(). + * @param {boolean} [autoStart=true] — start autonomous behaviors immediately + */ +export function initBehaviors(autoStart = true) { + if (initialized) return; + + for (const def of AGENT_DEFS) { + const ab = new AgentBehavior(def.id); + // Stagger initial timers so agents don't all act at once + ab.behaviorTimer = 2 + Math.random() * 8; + behaviors.set(def.id, ab); + } + + // Listen for WS behavior overrides + window.addEventListener('matrix:agent_behavior', onBehaviorOverride); + + initialized = true; + console.info('[Behaviors] Initialized for', behaviors.size, 'agents'); +} + +/** + * Update behavior system. Call each frame with delta in seconds. + * @param {number} delta — seconds since last frame + */ +export function updateBehaviors(delta) { + if (!initialized) return; + + // Throttle decision-making to save CPU + decisionAccumulator += delta; + if (decisionAccumulator < MIN_DECISION_INTERVAL) return; + const elapsed = decisionAccumulator; + decisionAccumulator = 0; + + for (const [id, ab] of behaviors) { + // Tick down WS override + if (ab._wsOverride) { + ab._wsOverrideTimer -= elapsed; + if (ab._wsOverrideTimer <= 0) { + ab._wsOverride = false; + } else { + continue; // skip autonomous decision while WS override is active + } + } + + // Tick down current behavior timer + ab.behaviorTimer -= elapsed; + if (ab.behaviorTimer > 0) continue; + + // Time to pick a new behavior + const newBehavior = ab.pickNextBehavior(behaviors); + ab.currentBehavior = newBehavior; + ab.behaviorTimer = randRange(...(DURATIONS[newBehavior] || [5, 10])); + + // For return_home, set a fixed timer based on distance + if (newBehavior === 'return_home') { + ab.behaviorTimer = 15; // max time to get home + } + + // Execute the behavior + const executor = EXECUTORS[newBehavior]; + if (executor) executor(ab); + } +} + +/** + * Get current behavior for an agent. + * @param {string} agentId + * @returns {string|null} + */ +export function getAgentBehavior(agentId) { + const ab = behaviors.get(agentId); + return ab ? ab.currentBehavior : null; +} + +/** + * Dispose the behavior system. + */ +export function disposeBehaviors() { + window.removeEventListener('matrix:agent_behavior', onBehaviorOverride); + behaviors.clear(); + initialized = false; + decisionAccumulator = 0; +} diff --git a/frontend/js/config.js b/frontend/js/config.js new file mode 100644 index 0000000..a263c70 --- /dev/null +++ b/frontend/js/config.js @@ -0,0 +1,68 @@ +/** + * config.js — Connection configuration for The Matrix. + * + * Override at deploy time via URL query params: + * ?ws=ws://tower:8080/ws/world-state — WebSocket endpoint + * ?token=my-secret — Auth token (Phase 1 shared secret) + * ?mock=true — Force mock mode (no real WS) + * + * Or via Vite env vars: + * VITE_WS_URL — WebSocket endpoint + * VITE_WS_TOKEN — Auth token + * VITE_MOCK_MODE — 'true' to force mock mode + * + * Priority: URL params > env vars > defaults. + * + * Resolves Issue #7 — js/config.js + * Resolves Issue #11 — WS authentication strategy (Phase 1: shared secret) + */ + +const params = new URLSearchParams(window.location.search); + +function param(name, envKey, fallback) { + return params.get(name) + ?? (import.meta.env[envKey] || null) + ?? fallback; +} + +export const Config = Object.freeze({ + /** WebSocket endpoint. Empty string = no live connection (mock mode). */ + wsUrl: param('ws', 'VITE_WS_URL', ''), + + /** Auth token appended as ?token= query param on WS connect (Issue #11). */ + wsToken: param('token', 'VITE_WS_TOKEN', ''), + + /** Force mock mode even if wsUrl is set. Useful for local dev. */ + mockMode: param('mock', 'VITE_MOCK_MODE', 'false') === 'true', + + /** Reconnection timing */ + reconnectBaseMs: 2000, + reconnectMaxMs: 30000, + + /** Heartbeat / zombie detection */ + heartbeatIntervalMs: 30000, + heartbeatTimeoutMs: 5000, + + /** + * Computed: should we use the real WebSocket client? + * True when wsUrl is non-empty AND mockMode is false. + */ + get isLive() { + return this.wsUrl !== '' && !this.mockMode; + }, + + /** + * Build the final WS URL with auth token appended as a query param. + * Returns null if not in live mode. + * + * Result: ws://tower:8080/ws/world-state?token=my-secret + */ + get wsUrlWithAuth() { + if (!this.isLive) return null; + const url = new URL(this.wsUrl); + if (this.wsToken) { + url.searchParams.set('token', this.wsToken); + } + return url.toString(); + }, +}); diff --git a/frontend/js/demo.js b/frontend/js/demo.js new file mode 100644 index 0000000..07db234 --- /dev/null +++ b/frontend/js/demo.js @@ -0,0 +1,261 @@ +/** + * demo.js — Demo autopilot for standalone mode. + * + * When The Matrix runs without a live backend (mock mode), this module + * simulates realistic activity: agent state changes, sat flow payments, + * economy updates, chat messages, streaming tokens, and connection pulses. + * + * The result is a self-running showcase of every visual feature. + * + * Start with `startDemo()`, stop with `stopDemo()`. + */ + +import { AGENT_DEFS, colorToCss } from './agent-defs.js'; +import { setAgentState, setAgentWalletHealth, getAgentPosition, pulseConnection } from './agents.js'; +import { triggerSatFlow } from './satflow.js'; +import { updateEconomyStatus } from './economy.js'; +import { appendChatMessage, startStreamingMessage } from './ui.js'; +import { showBark } from './bark.js'; +import { setAmbientState } from './ambient.js'; + +/* ── Demo script data ── */ + +const AGENT_IDS = AGENT_DEFS.map(d => d.id); + +const CHAT_LINES = [ + { agent: 'timmy', text: 'Cycle 544 complete. All tests green.' }, + { agent: 'perplexity', text: 'Smoke test 82/82 pass. Merging to main.' }, + { agent: 'replit', text: 'Admin relay refactored. Queue depth nominal.' }, + { agent: 'kimi', text: 'Deep research request filed. Scanning sources.' }, + { agent: 'claude', text: 'Code review done — looks clean, ship it.' }, + { agent: 'timmy', text: 'Invoice for 2,100 sats approved. Paying out.' }, + { agent: 'perplexity', text: 'New feature branch pushed: feat/demo-autopilot.' }, + { agent: 'replit', text: 'Strfry relay stats: 147 events/sec, 0 errors.' }, + { agent: 'kimi', text: 'Found 3 relevant papers. Summarizing now.' }, + { agent: 'claude', text: 'Edge case in the reconnect logic — filing a fix.' }, + { agent: 'timmy', text: 'The Tower stands. Another block confirmed.' }, + { agent: 'perplexity', text: 'Integration doc updated. Protocol v2 complete.' }, + { agent: 'replit', text: 'Nostr identity verified. Pubkey registered.' }, + { agent: 'kimi', text: 'Research complete. Report saved to workspace.' }, + { agent: 'claude', text: 'Streaming tokens working. Cursor blinks on cue.' }, +]; + +const STREAM_LINES = [ + { agent: 'timmy', text: 'Analyzing commit history... Pattern detected: build velocity is increasing. The Tower grows stronger each cycle.' }, + { agent: 'perplexity', text: 'Running integration checks against the protocol spec. All 9 message types verified. Gateway adapter is ready for the next phase.' }, + { agent: 'kimi', text: 'Deep scan complete. Three high-signal sources found. Compiling synthesis with citations and confidence scores.' }, + { agent: 'claude', text: 'Reviewing the diff: 47 lines added, 12 removed. Logic is clean. Recommending merge with one minor style suggestion.' }, + { agent: 'replit', text: 'Relay metrics nominal. Throughput: 200 events/sec peak, 92 sustained. Memory stable at 128MB. No reconnection events.' }, +]; + +const BARK_LINES = [ + { text: 'The Tower watches. The Tower remembers.', agent: 'timmy', emotion: 'calm' }, + { text: 'A visitor. Welcome to the Workshop.', agent: 'timmy', emotion: 'calm' }, + { text: 'New commit on main. The code evolves.', agent: 'timmy', emotion: 'excited' }, + { text: '222 — the number echoes again.', agent: 'timmy', emotion: 'calm' }, + { text: 'Sovereignty means running your own mind.', agent: 'timmy', emotion: 'calm' }, + { text: 'Five agents, one mission. Build.', agent: 'perplexity', emotion: 'focused' }, + { text: 'The relay hums. Events flow like water.', agent: 'replit', emotion: 'contemplative' }, +]; + +/* ── Economy simulation state ── */ + +const economyState = { + treasury_sats: 500000, + treasury_usd: 4.85, + agents: {}, + recent_transactions: [], +}; + +function initEconomyState() { + for (const def of AGENT_DEFS) { + economyState.agents[def.id] = { + balance_sats: 50000 + Math.floor(Math.random() * 100000), + reserved_sats: 20000 + Math.floor(Math.random() * 30000), + spent_today_sats: Math.floor(Math.random() * 15000), + }; + } +} + +/* ── Timers ── */ + +const timers = []; +let running = false; + +function schedule(fn, minMs, maxMs) { + if (!running) return; + const delay = minMs + Math.random() * (maxMs - minMs); + const id = setTimeout(() => { + if (!running) return; + fn(); + schedule(fn, minMs, maxMs); + }, delay); + timers.push(id); +} + +/* ── Demo behaviors ── */ + +function randomAgent() { + return AGENT_IDS[Math.floor(Math.random() * AGENT_IDS.length)]; +} + +function randomPair() { + const a = randomAgent(); + let b = randomAgent(); + while (b === a) b = randomAgent(); + return [a, b]; +} + +function pick(arr) { + return arr[Math.floor(Math.random() * arr.length)]; +} + +/** Cycle agents through active/idle states */ +function demoStateChange() { + const agentId = randomAgent(); + const state = Math.random() > 0.4 ? 'active' : 'idle'; + setAgentState(agentId, state); + + // If going active, return to idle after 3-8s + if (state === 'active') { + const revert = setTimeout(() => { + if (running) setAgentState(agentId, 'idle'); + }, 3000 + Math.random() * 5000); + timers.push(revert); + } +} + +/** Fire sat flow between two agents */ +function demoPayment() { + const [from, to] = randomPair(); + const fromPos = getAgentPosition(from); + const toPos = getAgentPosition(to); + if (fromPos && toPos) { + const amount = 100 + Math.floor(Math.random() * 5000); + triggerSatFlow(fromPos, toPos, amount); + + // Update economy state + const fromData = economyState.agents[from]; + const toData = economyState.agents[to]; + if (fromData) fromData.spent_today_sats += amount; + if (toData) toData.balance_sats += amount; + economyState.recent_transactions.push({ + from, to, amount_sats: amount, + }); + if (economyState.recent_transactions.length > 5) { + economyState.recent_transactions.shift(); + } + } +} + +/** Update the economy panel with simulated data */ +function demoEconomy() { + // Drift treasury and agent balances slightly + economyState.treasury_sats += Math.floor((Math.random() - 0.3) * 2000); + economyState.treasury_usd = economyState.treasury_sats / 100000; + + for (const id of AGENT_IDS) { + const data = economyState.agents[id]; + if (data) { + data.balance_sats += Math.floor((Math.random() - 0.4) * 1000); + data.balance_sats = Math.max(500, data.balance_sats); + } + } + + updateEconomyStatus({ ...economyState }); + + // Update wallet health glow on agents + for (const id of AGENT_IDS) { + const data = economyState.agents[id]; + if (data) { + const health = Math.min(1, data.balance_sats / Math.max(1, data.reserved_sats * 3)); + setAgentWalletHealth(id, health); + } + } +} + +/** Show a chat message from a random agent */ +function demoChat() { + const line = pick(CHAT_LINES); + const def = AGENT_DEFS.find(d => d.id === line.agent); + if (def) { + appendChatMessage(def.label, line.text, colorToCss(def.color)); + } +} + +/** Stream a message word-by-word */ +function demoStream() { + const line = pick(STREAM_LINES); + const def = AGENT_DEFS.find(d => d.id === line.agent); + if (!def) return; + + const stream = startStreamingMessage(def.label, colorToCss(def.color)); + const words = line.text.split(' '); + let i = 0; + + const wordTimer = setInterval(() => { + if (!running || i >= words.length) { + clearInterval(wordTimer); + if (stream && stream.finish) stream.finish(); + return; + } + const token = (i === 0 ? '' : ' ') + words[i]; + if (stream && stream.push) stream.push(token); + i++; + }, 60 + Math.random() * 80); + + timers.push(wordTimer); +} + +/** Pulse a connection line between two agents */ +function demoPulse() { + const [a, b] = randomPair(); + pulseConnection(a, b, 3000 + Math.random() * 3000); +} + +/** Cycle ambient mood */ +const MOODS = ['calm', 'focused', 'storm', 'night', 'dawn']; +let moodIndex = 0; +function demoAmbient() { + moodIndex = (moodIndex + 1) % MOODS.length; + setAmbientState(MOODS[moodIndex]); +} + +/** Show a bark */ +function demoBark() { + const line = pick(BARK_LINES); + showBark({ text: line.text, agentId: line.agent, emotion: line.emotion }); +} + +/* ── Public API ── */ + +export function startDemo() { + if (running) return; + running = true; + + initEconomyState(); + + // Initial economy push so the panel isn't empty + demoEconomy(); + + // Set initial wallet health + for (const id of AGENT_IDS) { + setAgentWalletHealth(id, 0.5 + Math.random() * 0.5); + } + + // Schedule recurring demo events at realistic intervals + schedule(demoStateChange, 2000, 5000); // state changes: every 2-5s + schedule(demoPayment, 6000, 15000); // payments: every 6-15s + schedule(demoEconomy, 8000, 20000); // economy updates: every 8-20s + schedule(demoChat, 5000, 12000); // chat messages: every 5-12s + schedule(demoStream, 20000, 40000); // streaming: every 20-40s + schedule(demoPulse, 4000, 10000); // connection pulses: every 4-10s + schedule(demoBark, 18000, 35000); // barks: every 18-35s + schedule(demoAmbient, 30000, 60000); // ambient mood: every 30-60s +} + +export function stopDemo() { + running = false; + for (const id of timers) clearTimeout(id); + timers.length = 0; +} diff --git a/frontend/js/economy.js b/frontend/js/economy.js new file mode 100644 index 0000000..141155d --- /dev/null +++ b/frontend/js/economy.js @@ -0,0 +1,100 @@ +/** + * economy.js — Wallet & treasury panel for the Matrix HUD. + * + * Displays the system treasury, per-agent balances, and recent + * transactions in a compact panel anchored to the bottom-left + * (above the chat). Updated by `economy_status` WS messages. + * + * Resolves Issue #17 — Wallet & treasury panel + */ + +let $panel = null; +let latestStatus = null; + +/* ── API ── */ + +export function initEconomy() { + $panel = document.getElementById('economy-panel'); + if (!$panel) return; + _render(null); +} + +/** + * Update the economy display with fresh data. + * @param {object} status — economy_status WS payload + */ +export function updateEconomyStatus(status) { + latestStatus = status; + _render(status); +} + +export function disposeEconomy() { + latestStatus = null; + if ($panel) $panel.innerHTML = ''; +} + +/* ── Render ── */ + +function _render(status) { + if (!$panel) return; + + if (!status) { + $panel.innerHTML = ` +
TREASURY
+
Awaiting economy data…
+ `; + return; + } + + const treasury = _formatSats(status.treasury_sats || 0); + const usd = status.treasury_usd != null ? ` ($${status.treasury_usd.toFixed(2)})` : ''; + + // Per-agent rows + const agents = status.agents || {}; + const agentRows = Object.entries(agents).map(([id, data]) => { + const bal = _formatSats(data.balance_sats || 0); + const spent = _formatSats(data.spent_today_sats || 0); + const health = data.balance_sats != null && data.reserved_sats != null + ? Math.min(1, data.balance_sats / Math.max(1, data.reserved_sats * 3)) + : 1; + const healthColor = health > 0.5 ? '#00ff41' : health > 0.2 ? '#ffaa00' : '#ff4422'; + + return ` +
+ + ${_esc(id.toUpperCase())} + ${bal} + -${spent} +
+ `; + }).join(''); + + // Recent transactions (last 3) + const txns = (status.recent_transactions || []).slice(-3); + const txnRows = txns.map(tx => { + const amt = _formatSats(tx.amount_sats || 0); + const arrow = `${_esc((tx.from || '?').toUpperCase())} → ${_esc((tx.to || '?').toUpperCase())}`; + return `
${arrow} ${amt}
`; + }).join(''); + + $panel.innerHTML = ` +
+ TREASURY + ${treasury}${_esc(usd)} +
+ ${agentRows ? `
${agentRows}
` : ''} + ${txnRows ? `
RECENT
${txnRows}
` : ''} + `; +} + +/* ── Helpers ── */ + +function _formatSats(sats) { + if (sats >= 1000000) return (sats / 1000000).toFixed(1) + 'M ₿'; + if (sats >= 1000) return (sats / 1000).toFixed(1) + 'k ₿'; + return sats.toLocaleString() + ' ₿'; +} + +function _esc(str) { + return String(str).replace(/&/g, '&').replace(//g, '>'); +} diff --git a/frontend/js/effects.js b/frontend/js/effects.js new file mode 100644 index 0000000..3f314ee --- /dev/null +++ b/frontend/js/effects.js @@ -0,0 +1,195 @@ +/** + * effects.js — Matrix rain + starfield particle effects. + * + * Optimizations (Issue #34): + * - Frame skipping on low-tier hardware (update every 2nd frame) + * - Bounding sphere set to skip Three.js per-particle frustum test + * - Tight typed-array loop with stride-3 addressing (no object allocation) + * - Particles recycle to camera-relative region on respawn for density + * - drawRange used to soft-limit visible particles if FPS drops + */ +import * as THREE from 'three'; +import { getQualityTier } from './quality.js'; +import { getRainSpeedMultiplier, getRainOpacity, getStarOpacity } from './ambient.js'; + +let rainParticles; +let rainPositions; +let rainVelocities; +let rainCount = 0; +let skipFrames = 0; // 0 = update every frame, 1 = every 2nd frame +let frameCounter = 0; +let starfield = null; + +/** Adaptive draw range — reduced if FPS drops below threshold. */ +let activeCount = 0; +const FPS_FLOOR = 20; +const ADAPT_INTERVAL_MS = 2000; +let lastFpsCheck = 0; +let fpsAccum = 0; +let fpsSamples = 0; + +export function initEffects(scene) { + const tier = getQualityTier(); + skipFrames = tier === 'low' ? 1 : 0; + initMatrixRain(scene, tier); + initStarfield(scene, tier); +} + +function initMatrixRain(scene, tier) { + rainCount = tier === 'low' ? 500 : tier === 'medium' ? 1200 : 2000; + activeCount = rainCount; + + const geo = new THREE.BufferGeometry(); + const positions = new Float32Array(rainCount * 3); + const velocities = new Float32Array(rainCount); + const colors = new Float32Array(rainCount * 3); + + for (let i = 0; i < rainCount; i++) { + const i3 = i * 3; + positions[i3] = (Math.random() - 0.5) * 100; + positions[i3 + 1] = Math.random() * 50 + 5; + positions[i3 + 2] = (Math.random() - 0.5) * 100; + velocities[i] = 0.05 + Math.random() * 0.15; + + const brightness = 0.3 + Math.random() * 0.7; + colors[i3] = 0; + colors[i3 + 1] = brightness; + colors[i3 + 2] = 0; + } + + geo.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + geo.setAttribute('color', new THREE.BufferAttribute(colors, 3)); + + // Pre-compute bounding sphere so Three.js skips per-frame recalc. + // Rain spans ±50 XZ, 0–60 Y — a sphere from origin with r=80 covers it. + geo.boundingSphere = new THREE.Sphere(new THREE.Vector3(0, 25, 0), 80); + + rainPositions = positions; + rainVelocities = velocities; + + const mat = new THREE.PointsMaterial({ + size: tier === 'low' ? 0.16 : 0.12, + vertexColors: true, + transparent: true, + opacity: 0.7, + sizeAttenuation: true, + }); + + rainParticles = new THREE.Points(geo, mat); + rainParticles.frustumCulled = false; // We manage visibility ourselves + scene.add(rainParticles); +} + +function initStarfield(scene, tier) { + const count = tier === 'low' ? 150 : tier === 'medium' ? 350 : 500; + const geo = new THREE.BufferGeometry(); + const positions = new Float32Array(count * 3); + + for (let i = 0; i < count; i++) { + const i3 = i * 3; + positions[i3] = (Math.random() - 0.5) * 300; + positions[i3 + 1] = Math.random() * 80 + 10; + positions[i3 + 2] = (Math.random() - 0.5) * 300; + } + + geo.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + geo.boundingSphere = new THREE.Sphere(new THREE.Vector3(0, 40, 0), 200); + + const mat = new THREE.PointsMaterial({ + color: 0x003300, + size: 0.08, + transparent: true, + opacity: 0.5, + }); + + starfield = new THREE.Points(geo, mat); + starfield.frustumCulled = false; + scene.add(starfield); +} + +/** + * Feed current FPS into the adaptive particle budget. + * Called externally from the render loop. + */ +export function feedFps(fps) { + fpsAccum += fps; + fpsSamples++; +} + +export function updateEffects(_time) { + if (!rainParticles) return; + + // On low tier, skip every other frame to halve iteration cost + if (skipFrames > 0) { + frameCounter++; + if (frameCounter % (skipFrames + 1) !== 0) return; + } + + const velocityMul = (skipFrames > 0 ? (skipFrames + 1) : 1) * getRainSpeedMultiplier(); + + // Apply ambient-driven opacity + if (rainParticles.material.opacity !== getRainOpacity()) { + rainParticles.material.opacity = getRainOpacity(); + } + if (starfield && starfield.material.opacity !== getStarOpacity()) { + starfield.material.opacity = getStarOpacity(); + } + + // Adaptive particle budget — check every ADAPT_INTERVAL_MS + const now = _time; + if (now - lastFpsCheck > ADAPT_INTERVAL_MS && fpsSamples > 0) { + const avgFps = fpsAccum / fpsSamples; + fpsAccum = 0; + fpsSamples = 0; + lastFpsCheck = now; + + if (avgFps < FPS_FLOOR && activeCount > 200) { + // Drop 20% of particles to recover frame rate + activeCount = Math.max(200, Math.floor(activeCount * 0.8)); + } else if (avgFps > FPS_FLOOR + 10 && activeCount < rainCount) { + // Recover particles gradually + activeCount = Math.min(rainCount, Math.floor(activeCount * 1.1)); + } + rainParticles.geometry.setDrawRange(0, activeCount); + } + + // Tight loop — stride-3 addressing, no object allocation + const pos = rainPositions; + const vel = rainVelocities; + const count = activeCount; + + for (let i = 0; i < count; i++) { + const yIdx = i * 3 + 1; + pos[yIdx] -= vel[i] * velocityMul; + if (pos[yIdx] < -1) { + pos[yIdx] = 40 + Math.random() * 20; + pos[i * 3] = (Math.random() - 0.5) * 100; + pos[i * 3 + 2] = (Math.random() - 0.5) * 100; + } + } + + rainParticles.geometry.attributes.position.needsUpdate = true; +} + +/** + * Dispose all effect resources (used on world teardown). + */ +export function disposeEffects() { + if (rainParticles) { + rainParticles.geometry.dispose(); + rainParticles.material.dispose(); + rainParticles = null; + } + if (starfield) { + starfield.geometry.dispose(); + starfield.material.dispose(); + starfield = null; + } + rainPositions = null; + rainVelocities = null; + rainCount = 0; + activeCount = 0; + frameCounter = 0; + fpsAccum = 0; + fpsSamples = 0; +} diff --git a/frontend/js/interaction.js b/frontend/js/interaction.js new file mode 100644 index 0000000..2712133 --- /dev/null +++ b/frontend/js/interaction.js @@ -0,0 +1,340 @@ +/** + * interaction.js — Camera controls + agent touch/click interaction. + * + * Adds raycasting so users can tap/click on agents to see their info + * and optionally start a conversation. The info popup appears as a + * DOM overlay anchored near the clicked agent. + * + * Resolves Issue #44 — Touch-to-interact + */ +import * as THREE from 'three'; +import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; +import { getAgentDefs } from './agents.js'; +import { colorToCss } from './agent-defs.js'; + +let controls; +let camera; +let renderer; +let scene; + +/* ── Raycasting state ── */ +const raycaster = new THREE.Raycaster(); +const pointer = new THREE.Vector2(); + +/** Currently selected agent id (null if nothing selected) */ +let selectedAgentId = null; + +/** The info popup DOM element */ +let $popup = null; + +/* ── Public API ── */ + +export function initInteraction(cam, ren, scn) { + camera = cam; + renderer = ren; + scene = scn; + + controls = new OrbitControls(camera, renderer.domElement); + controls.enableDamping = true; + controls.dampingFactor = 0.05; + controls.screenSpacePanning = false; + controls.minDistance = 5; + controls.maxDistance = 80; + controls.maxPolarAngle = Math.PI / 2.1; + controls.target.set(0, 0, 0); + controls.update(); + + renderer.domElement.addEventListener('contextmenu', e => e.preventDefault()); + + // Pointer events (works for mouse and touch) + renderer.domElement.addEventListener('pointerdown', _onPointerDown, { passive: true }); + renderer.domElement.addEventListener('pointermove', _onPointerMove, { passive: true }); + renderer.domElement.addEventListener('pointerup', _onPointerUp, { passive: true }); + + _ensurePopup(); +} + +export function updateControls() { + if (controls) controls.update(); +} + +/** + * Called each frame from the render loop so the popup can track a + * selected agent's screen position. + */ +export function updateInteraction() { + if (!selectedAgentId || !$popup || $popup.style.display === 'none') return; + _positionPopup(selectedAgentId); +} + +/** Deselect the current agent and hide the popup. */ +export function deselectAgent() { + selectedAgentId = null; + if ($popup) $popup.style.display = 'none'; +} + +/** + * Dispose orbit controls and event listeners (used on world teardown). + */ +export function disposeInteraction() { + if (controls) { + controls.dispose(); + controls = null; + } + if (renderer) { + renderer.domElement.removeEventListener('pointerdown', _onPointerDown); + renderer.domElement.removeEventListener('pointermove', _onPointerMove); + renderer.domElement.removeEventListener('pointerup', _onPointerUp); + } + deselectAgent(); +} + +/* ── Internal: pointer handling ── */ + +let _pointerDownPos = { x: 0, y: 0 }; +let _pointerMoved = false; + +function _onPointerDown(e) { + _pointerDownPos.x = e.clientX; + _pointerDownPos.y = e.clientY; + _pointerMoved = false; +} + +function _onPointerMove(e) { + const dx = e.clientX - _pointerDownPos.x; + const dy = e.clientY - _pointerDownPos.y; + if (Math.abs(dx) + Math.abs(dy) > 6) _pointerMoved = true; +} + +function _onPointerUp(e) { + // Ignore drags — only respond to taps/clicks + if (_pointerMoved) return; + _handleTap(e.clientX, e.clientY); +} + +/* ── Raycasting ── */ + +function _handleTap(clientX, clientY) { + if (!camera || !scene) return; + + pointer.x = (clientX / window.innerWidth) * 2 - 1; + pointer.y = -(clientY / window.innerHeight) * 2 + 1; + raycaster.setFromCamera(pointer, camera); + + // Collect all agent group meshes + const agentDefs = getAgentDefs(); + const meshes = []; + for (const def of agentDefs) { + // Each agent group is a direct child of the scene + scene.traverse(child => { + if (child.isGroup && child.children.length > 0) { + // Check if this group's first mesh color matches an agent + const coreMesh = child.children.find(c => c.isMesh && c.geometry?.type === 'IcosahedronGeometry'); + if (coreMesh) { + meshes.push({ mesh: child, agentId: _matchGroupToAgent(child, agentDefs) }); + } + } + }); + break; // only need to traverse once + } + + // Raycast against all scene objects, find the nearest agent group or memory orb + const allMeshes = []; + scene.traverse(obj => { if (obj.isMesh) allMeshes.push(obj); }); + const intersects = raycaster.intersectObjects(allMeshes, false); + + let hitAgentId = null; + let hitFact = null; + + for (const hit of intersects) { + // 1. Check if it's a memory orb + if (hit.object.id && hit.object.id.startsWith('fact_')) { + hitFact = { + id: hit.object.id, + data: hit.object.userData + }; + break; + } + + // 2. Walk up to find the agent group + let obj = hit.object; + while (obj && obj.parent) { + const matched = _matchGroupToAgent(obj, agentDefs); + if (matched) { + hitAgentId = matched; + break; + } + obj = obj.parent; + } + if (hitAgentId) break; + } + + if (hitAgentId) { + _selectAgent(hitAgentId); + } else if (hitFact) { + _selectFact(hitFact.id, hitFact.data); + } else { + deselectAgent(); + } +} + +/** + * Try to match a Three.js group to an agent by comparing positions. + */ +function _matchGroupToAgent(group, agentDefs) { + if (!group.isGroup) return null; + for (const def of agentDefs) { + // Agent positions: (def.x, ~0, def.z) — the group y bobs, so just check xz + const dx = Math.abs(group.position.x - (def.position?.x ?? 0)); + const dz = Math.abs(group.position.z - (def.position?.z ?? 0)); + // getAgentDefs returns { id, label, role, color, state } — no position. + // We need to compare the group position to the known AGENT_DEFS x/z. + // Since getAgentDefs doesn't return position, match by finding the icosahedron + // core color against agent color. + const coreMesh = group.children.find(c => c.isMesh && c.material?.emissive); + if (coreMesh) { + const meshColor = coreMesh.material.color.getHex(); + if (meshColor === def.color) return def.id; + } + } + return null; +} + +/* ── Agent selection & popup ── */ + +function _selectAgent(agentId) { + selectedAgentId = agentId; + const defs = getAgentDefs(); + const agent = defs.find(d => d.id === agentId); + if (!agent) return; + + _ensurePopup(); + const color = colorToCss(agent.color); + const stateLabel = (agent.state || 'idle').toUpperCase(); + const stateColor = agent.state === 'active' ? '#00ff41' : '#33aa55'; + + $popup.innerHTML = ` +
+ ${_esc(agent.label)} + × +
+
${_esc(agent.role)}
+
● ${stateLabel}
+ + `; + $popup.style.display = 'block'; + + // Position near agent + _positionPopup(agentId); + + // Close button + const $close = document.getElementById('agent-popup-close'); + if ($close) $close.addEventListener('click', deselectAgent); + + // Talk button — focus the chat input and prefill + const $talk = document.getElementById('agent-popup-talk'); + if ($talk) { + $talk.addEventListener('click', () => { + const $input = document.getElementById('chat-input'); + if ($input) { + $input.focus(); + $input.placeholder = `Say something to ${agent.label}...`; + } + deselectAgent(); + }); + } +} + +function _selectFact(factId, data) { + selectedAgentId = null; // clear agent selection + _ensurePopup(); + + const categoryColors = { + user_pref: '#00ffaa', + project: '#00aaff', + tool: '#ffaa00', + general: '#ffffff', + }; + const color = categoryColors[data.category] || '#cccccc'; + + $popup.innerHTML = ` +
+ Memory Fact + × +
+
Category: ${_esc(data.category || 'general')}
+
${_esc(data.content)}
+
ID: ${_esc(factId)}
+ `; + $popup.style.display = 'block'; + + _positionPopup(factId); + + const $close = document.getElementById('agent-popup-close'); + if ($close) $close.addEventListener('click', deselectAgent); +} + +function _positionPopup(id) { + if (!camera || !renderer || !$popup) return; + + let targetObj = null; + scene.traverse(obj => { + if (targetObj) return; + // If it's an agent ID, we find the group. If it's a fact ID, we find the mesh. + if (id.startsWith('fact_')) { + if (obj.id === id) targetObj = obj; + } else { + if (obj.isGroup) { + const defs = getAgentDefs(); + const def = defs.find(d => d.id === id); + if (def) { + const core = obj.children.find(c => c.isMesh && c.material?.emissive); + if (core && core.material.color.getHex() === def.color) { + targetObj = obj; + } + } + } + } + }); + + if (!targetObj) return; + + const worldPos = new THREE.Vector3(); + targetObj.getWorldPosition(worldPos); + worldPos.y += 1.5; + + const screenPos = worldPos.clone().project(camera); + const hw = window.innerWidth / 2; + const hh = window.innerHeight / 2; + const sx = screenPos.x * hw + hw; + const sy = -screenPos.y * hh + hh; + + if (screenPos.z > 1) { + $popup.style.display = 'none'; + return; + } + + const popW = $popup.offsetWidth || 180; + const popH = $popup.offsetHeight || 120; + const x = Math.min(Math.max(sx - popW / 2, 8), window.innerWidth - popW - 8); + const y = Math.min(Math.max(sy - popH - 12, 8), window.innerHeight - popH - 60); + + $popup.style.left = x + 'px'; + $popup.style.top = y + 'px'; +} + +/* ── Popup DOM ── */ + +function _ensurePopup() { + if ($popup) return; + $popup = document.createElement('div'); + $popup.id = 'agent-popup'; + $popup.style.display = 'none'; + document.body.appendChild($popup); +} + +function _esc(str) { + return String(str).replace(/&/g,'&').replace(//g,'>'); +} diff --git a/frontend/js/main.js b/frontend/js/main.js new file mode 100644 index 0000000..3da13c3 --- /dev/null +++ b/frontend/js/main.js @@ -0,0 +1,180 @@ +import { initWorld, onWindowResize, disposeWorld } from './world.js'; +import { + initAgents, updateAgents, getAgentCount, + disposeAgents, getAgentStates, applyAgentStates, +} from './agents.js'; +import { initEffects, updateEffects, disposeEffects, feedFps } from './effects.js'; +import { initUI, updateUI } from './ui.js'; +import { initInteraction, updateControls, updateInteraction, disposeInteraction } from './interaction.js'; +import { initAmbient, updateAmbient, disposeAmbient } from './ambient.js'; +import { initSatFlow, updateSatFlow, disposeSatFlow } from './satflow.js'; +import { initEconomy, disposeEconomy } from './economy.js'; +import { initWebSocket, getConnectionState, getJobCount } from './websocket.js'; +import { initVisitor } from './visitor.js'; +import { initPresence, disposePresence } from './presence.js'; +import { initTranscript } from './transcript.js'; +import { initAvatar, updateAvatar, getAvatarMainCamera, renderAvatarPiP, disposeAvatar } from './avatar.js'; +import { initSceneObjects, updateSceneObjects, clearSceneObjects } from './scene-objects.js'; +import { updateZones } from './zones.js'; +import { initBehaviors, updateBehaviors, disposeBehaviors } from './behaviors.js'; + +let running = false; +let canvas = null; + +/** + * Build (or rebuild) the Three.js world. + * + * @param {boolean} firstInit + * true — first page load: also starts UI, WebSocket, and visitor + * false — context-restore reinit: skips UI/WS (they survive context loss) + * @param {Object.|null} stateSnapshot + * Agent state map captured just before teardown; reapplied after initAgents. + */ +function buildWorld(firstInit, stateSnapshot) { + const { scene, camera, renderer } = initWorld(canvas); + canvas = renderer.domElement; + + initEffects(scene); + initAgents(scene); + + if (stateSnapshot) { + applyAgentStates(stateSnapshot); + } + + initSceneObjects(scene); + initBehaviors(); // autonomous agent behaviors (#68) + initAvatar(scene, camera, renderer); + initInteraction(camera, renderer, scene); + initAmbient(scene); + initSatFlow(scene); + + if (firstInit) { + initUI(); + initEconomy(); + initWebSocket(scene); + initVisitor(); + initPresence(); + initTranscript(); + + // Dismiss loading screen + const loadingScreen = document.getElementById('loading-screen'); + if (loadingScreen) loadingScreen.classList.add('hidden'); + } + + // Debounce resize to 1 call per frame + const ac = new AbortController(); + let resizeFrame = null; + window.addEventListener('resize', () => { + if (resizeFrame) cancelAnimationFrame(resizeFrame); + resizeFrame = requestAnimationFrame(() => onWindowResize(camera, renderer)); + }, { signal: ac.signal }); + + let frameCount = 0; + let lastFpsTime = performance.now(); + let currentFps = 0; + let rafId = null; + + let lastTime = performance.now(); + + running = true; + + function animate() { + if (!running) return; + rafId = requestAnimationFrame(animate); + + const now = performance.now(); + const delta = Math.min((now - lastTime) / 1000, 0.1); + lastTime = now; + frameCount++; + if (now - lastFpsTime >= 1000) { + currentFps = Math.round(frameCount * 1000 / (now - lastFpsTime)); + frameCount = 0; + lastFpsTime = now; + } + + updateControls(); + updateInteraction(); + updateAmbient(delta); + updateSatFlow(delta); + feedFps(currentFps); + updateEffects(now); + updateAgents(now, delta); + updateBehaviors(delta); + updateSceneObjects(now, delta); + updateZones(null); // portal handler wired via loadWorld in websocket.js + + updateAvatar(delta); + updateUI({ + fps: currentFps, + agentCount: getAgentCount(), + jobCount: getJobCount(), + connectionState: getConnectionState(), + }); + + renderer.render(scene, getAvatarMainCamera()); + renderAvatarPiP(scene); + } + + // Pause rendering when tab is backgrounded (saves battery on iPad PWA) + document.addEventListener('visibilitychange', () => { + if (document.hidden) { + if (rafId) { + cancelAnimationFrame(rafId); + rafId = null; + running = false; + } + } else { + if (!running) { + running = true; + animate(); + } + } + }); + + animate(); + + return { scene, renderer, ac }; +} + +function teardown({ scene, renderer, ac }) { + running = false; + ac.abort(); + disposeAvatar(); + disposeInteraction(); + disposeAmbient(); + disposeSatFlow(); + disposeEconomy(); + disposeEffects(); + disposePresence(); + clearSceneObjects(); + disposeBehaviors(); + disposeAgents(); + disposeWorld(renderer, scene); +} + +function main() { + const $overlay = document.getElementById('webgl-recovery-overlay'); + + let handle = buildWorld(true, null); + + // WebGL context loss recovery (iPad PWA, GPU driver reset, etc.) + canvas.addEventListener('webglcontextlost', event => { + event.preventDefault(); + running = false; + if ($overlay) $overlay.style.display = 'flex'; + }); + + canvas.addEventListener('webglcontextrestored', () => { + const snapshot = getAgentStates(); + teardown(handle); + handle = buildWorld(false, snapshot); + if ($overlay) $overlay.style.display = 'none'; + }); +} + +main(); + +// Register service worker only in production builds +if (import.meta.env.PROD && 'serviceWorker' in navigator) { + navigator.serviceWorker.register('/sw.js').catch(() => {}); +} diff --git a/frontend/js/presence.js b/frontend/js/presence.js new file mode 100644 index 0000000..c22acfe --- /dev/null +++ b/frontend/js/presence.js @@ -0,0 +1,139 @@ +/** + * presence.js — Agent Presence HUD for The Matrix. + * + * Shows a live "who's online" panel with connection status indicators, + * uptime tracking, and animated pulse dots per agent. Updates every second. + * + * In mock mode, all built-in agents show as "online" with simulated uptime. + * In live mode, the panel reacts to WS events (agent_state, agent_joined, agent_left). + * + * Resolves Issue #53 + */ + +import { AGENT_DEFS, colorToCss } from './agent-defs.js'; +import { getAgentDefs } from './agents.js'; +import { getConnectionState } from './websocket.js'; + +/** @type {HTMLElement|null} */ +let $panel = null; + +/** @type {Map} */ +const presence = new Map(); + +let updateInterval = null; + +/* ── Public API ── */ + +export function initPresence() { + $panel = document.getElementById('presence-hud'); + if (!$panel) return; + + // Initialize all built-in agents + const now = Date.now(); + for (const def of AGENT_DEFS) { + presence.set(def.id, { online: true, since: now }); + } + + // Initial render + render(); + + // Update every second for uptime tickers + updateInterval = setInterval(render, 1000); +} + +/** + * Mark an agent as online (called from websocket.js on agent_joined/agent_register). + */ +export function setAgentOnline(agentId) { + const entry = presence.get(agentId); + if (entry) { + entry.online = true; + entry.since = Date.now(); + } else { + presence.set(agentId, { online: true, since: Date.now() }); + } +} + +/** + * Mark an agent as offline (called from websocket.js on agent_left/disconnect). + */ +export function setAgentOffline(agentId) { + const entry = presence.get(agentId); + if (entry) { + entry.online = false; + } +} + +export function disposePresence() { + if (updateInterval) { + clearInterval(updateInterval); + updateInterval = null; + } + presence.clear(); +} + +/* ── Internal ── */ + +function formatUptime(ms) { + const totalSec = Math.floor(ms / 1000); + if (totalSec < 60) return `${totalSec}s`; + const min = Math.floor(totalSec / 60); + const sec = totalSec % 60; + if (min < 60) return `${min}m ${String(sec).padStart(2, '0')}s`; + const hr = Math.floor(min / 60); + const remMin = min % 60; + return `${hr}h ${String(remMin).padStart(2, '0')}m`; +} + +function render() { + if (!$panel) return; + + const connState = getConnectionState(); + const defs = getAgentDefs(); + const now = Date.now(); + + // In mock mode, all agents are "online" + const isMock = connState === 'mock'; + + let onlineCount = 0; + const rows = []; + + for (const def of defs) { + const p = presence.get(def.id); + const isOnline = isMock ? true : (p?.online ?? false); + if (isOnline) onlineCount++; + + const uptime = isOnline && p ? formatUptime(now - p.since) : '--'; + const color = colorToCss(def.color); + const stateLabel = def.state === 'active' ? 'ACTIVE' : 'IDLE'; + const dotClass = isOnline ? 'presence-dot online' : 'presence-dot offline'; + const stateColor = def.state === 'active' ? '#00ff41' : '#33aa55'; + + rows.push( + `
` + + `` + + `${escapeHtml(def.label)}` + + `${stateLabel}` + + `${uptime}` + + `
` + ); + } + + const modeLabel = isMock ? 'LOCAL' : (connState === 'connected' ? 'LIVE' : 'OFFLINE'); + const modeColor = connState === 'connected' ? '#00ff41' : (isMock ? '#33aa55' : '#553300'); + + $panel.innerHTML = + `
` + + `PRESENCE` + + `${onlineCount}/${defs.length}` + + `${modeLabel}` + + `
` + + rows.join(''); +} + +function escapeHtml(str) { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>'); +} diff --git a/frontend/js/quality.js b/frontend/js/quality.js new file mode 100644 index 0000000..3ff51bc --- /dev/null +++ b/frontend/js/quality.js @@ -0,0 +1,90 @@ +/** + * quality.js — Detect hardware capability and return a quality tier. + * + * Tiers: + * 'low' — older iPads, phones, low-end GPUs (reduce particles, simpler effects) + * 'medium' — mid-range (moderate particle count) + * 'high' — desktop, modern iPad Pro (full quality) + * + * Detection uses a combination of: + * - Device pixel ratio (low DPR = likely low-end) + * - Logical core count (navigator.hardwareConcurrency) + * - Device memory (navigator.deviceMemory, Chrome/Edge only) + * - Screen size (small viewport = likely mobile) + * - Touch capability (touch + small screen = phone/tablet) + * - WebGL renderer string (if available) + */ + +let cachedTier = null; + +export function getQualityTier() { + if (cachedTier) return cachedTier; + + let score = 0; + + // Core count: 1-2 = low, 4 = mid, 8+ = high + const cores = navigator.hardwareConcurrency || 2; + if (cores >= 8) score += 3; + else if (cores >= 4) score += 2; + else score += 0; + + // Device memory (Chrome/Edge): < 4GB = low, 4-8 = mid, 8+ = high + const mem = navigator.deviceMemory || 4; + if (mem >= 8) score += 3; + else if (mem >= 4) score += 2; + else score += 0; + + // Screen dimensions (logical pixels) + const maxDim = Math.max(window.screen.width, window.screen.height); + if (maxDim < 768) score -= 1; // phone + else if (maxDim >= 1920) score += 1; // large desktop + + // DPR: high DPR on small screens = more GPU work + const dpr = window.devicePixelRatio || 1; + if (dpr > 2 && maxDim < 1024) score -= 1; // retina phone + + // Touch-only device heuristic + const touchOnly = 'ontouchstart' in window && !window.matchMedia('(pointer: fine)').matches; + if (touchOnly) score -= 1; + + // Try reading WebGL renderer for GPU hints + try { + const canvas = document.createElement('canvas'); + const gl = canvas.getContext('webgl') || canvas.getContext('webgl2'); + if (gl) { + const debugExt = gl.getExtension('WEBGL_debug_renderer_info'); + if (debugExt) { + const renderer = gl.getParameter(debugExt.UNMASKED_RENDERER_WEBGL).toLowerCase(); + // Known low-end GPU strings + if (renderer.includes('swiftshader') || renderer.includes('llvmpipe') || renderer.includes('software')) { + score -= 3; // software renderer + } + if (renderer.includes('apple gpu') || renderer.includes('apple m')) { + score += 2; // Apple Silicon is good + } + } + gl.getExtension('WEBGL_lose_context')?.loseContext(); + } + } catch { + // Can't probe GPU, use other signals + } + + // Map score to tier + if (score <= 1) cachedTier = 'low'; + else if (score <= 4) cachedTier = 'medium'; + else cachedTier = 'high'; + + console.info(`[Matrix Quality] Tier: ${cachedTier} (score: ${score}, cores: ${cores}, mem: ${mem}GB, dpr: ${dpr}, touch: ${touchOnly})`); + + return cachedTier; +} + +/** + * Get the recommended pixel ratio cap for the renderer. + */ +export function getMaxPixelRatio() { + const tier = getQualityTier(); + if (tier === 'low') return 1; + if (tier === 'medium') return 1.5; + return 2; +} diff --git a/frontend/js/satflow.js b/frontend/js/satflow.js new file mode 100644 index 0000000..fa6256e --- /dev/null +++ b/frontend/js/satflow.js @@ -0,0 +1,261 @@ +/** + * satflow.js — Sat flow particle effects for Lightning payments. + * + * When a payment_flow event arrives, gold particles fly from sender + * to receiver along a bezier arc. On arrival, a brief burst radiates + * outward from the target agent. + * + * Resolves Issue #13 — Sat flow particle effects + */ +import * as THREE from 'three'; + +let scene = null; + +/* ── Pool management ── */ + +const MAX_ACTIVE_FLOWS = 6; +const activeFlows = []; + +/* ── Shared resources ── */ + +const SAT_COLOR = new THREE.Color(0xffcc00); +const BURST_COLOR = new THREE.Color(0xffee44); + +const particleGeo = new THREE.BufferGeometry(); +// Pre-build a single-point geometry for instancing via Points +const _singleVert = new Float32Array([0, 0, 0]); +particleGeo.setAttribute('position', new THREE.BufferAttribute(_singleVert, 3)); + +/* ── API ── */ + +/** + * Initialize the sat flow system. + * @param {THREE.Scene} scn + */ +export function initSatFlow(scn) { + scene = scn; +} + +/** + * Trigger a sat flow animation between two world positions. + * + * @param {THREE.Vector3} fromPos — sender world position + * @param {THREE.Vector3} toPos — receiver world position + * @param {number} amountSats — payment amount (scales particle count) + */ +export function triggerSatFlow(fromPos, toPos, amountSats = 100) { + if (!scene) return; + + // Evict oldest flow if at capacity + if (activeFlows.length >= MAX_ACTIVE_FLOWS) { + const old = activeFlows.shift(); + _cleanupFlow(old); + } + + // Particle count: 5-20 based on amount, log-scaled + const count = Math.min(20, Math.max(5, Math.round(Math.log10(amountSats + 1) * 5))); + + const flow = _createFlow(fromPos.clone(), toPos.clone(), count); + activeFlows.push(flow); +} + +/** + * Per-frame update — advance all active flows. + * @param {number} delta — seconds since last frame + */ +export function updateSatFlow(delta) { + for (let i = activeFlows.length - 1; i >= 0; i--) { + const flow = activeFlows[i]; + flow.elapsed += delta; + + if (flow.phase === 'travel') { + _updateTravel(flow, delta); + if (flow.elapsed >= flow.duration) { + flow.phase = 'burst'; + flow.elapsed = 0; + _startBurst(flow); + } + } else if (flow.phase === 'burst') { + _updateBurst(flow, delta); + if (flow.elapsed >= flow.burstDuration) { + _cleanupFlow(flow); + activeFlows.splice(i, 1); + } + } + } +} + +/** + * Dispose all sat flow resources. + */ +export function disposeSatFlow() { + for (const flow of activeFlows) _cleanupFlow(flow); + activeFlows.length = 0; + scene = null; +} + +/* ── Internals: Flow lifecycle ── */ + +function _createFlow(from, to, count) { + // Bezier control point — arc upward + const mid = new THREE.Vector3().lerpVectors(from, to, 0.5); + mid.y += 3 + from.distanceTo(to) * 0.3; + + // Create particles + const positions = new Float32Array(count * 3); + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + geo.boundingSphere = new THREE.Sphere(mid, 50); + + const mat = new THREE.PointsMaterial({ + color: SAT_COLOR, + size: 0.25, + transparent: true, + opacity: 1.0, + blending: THREE.AdditiveBlending, + depthWrite: false, + sizeAttenuation: true, + }); + + const points = new THREE.Points(geo, mat); + scene.add(points); + + // Per-particle timing offsets (stagger the swarm) + const offsets = new Float32Array(count); + for (let i = 0; i < count; i++) { + offsets[i] = (i / count) * 0.4; // stagger over first 40% of duration + } + + return { + phase: 'travel', + elapsed: 0, + duration: 1.5 + from.distanceTo(to) * 0.05, // 1.5–2.5s depending on distance + from, to, mid, + count, + points, geo, mat, positions, + offsets, + burstPoints: null, + burstGeo: null, + burstMat: null, + burstPositions: null, + burstVelocities: null, + burstDuration: 0.6, + }; +} + +function _updateTravel(flow, _delta) { + const { from, to, mid, count, positions, offsets, elapsed, duration } = flow; + + for (let i = 0; i < count; i++) { + // Per-particle progress with stagger offset + let t = (elapsed - offsets[i]) / (duration - 0.4); + t = Math.max(0, Math.min(1, t)); + + // Quadratic bezier: B(t) = (1-t)²·P0 + 2(1-t)t·P1 + t²·P2 + const mt = 1 - t; + const i3 = i * 3; + positions[i3] = mt * mt * from.x + 2 * mt * t * mid.x + t * t * to.x; + positions[i3 + 1] = mt * mt * from.y + 2 * mt * t * mid.y + t * t * to.y; + positions[i3 + 2] = mt * mt * from.z + 2 * mt * t * mid.z + t * t * to.z; + + // Add slight wobble for organic feel + const wobble = Math.sin(elapsed * 12 + i * 1.7) * 0.08; + positions[i3] += wobble; + positions[i3 + 2] += wobble; + } + + flow.geo.attributes.position.needsUpdate = true; + + // Fade in/out + if (elapsed < 0.2) { + flow.mat.opacity = elapsed / 0.2; + } else if (elapsed > duration - 0.3) { + flow.mat.opacity = Math.max(0, (duration - elapsed) / 0.3); + } else { + flow.mat.opacity = 1.0; + } +} + +function _startBurst(flow) { + // Hide travel particles + if (flow.points) flow.points.visible = false; + + // Create burst particles at destination + const burstCount = 12; + const positions = new Float32Array(burstCount * 3); + const velocities = new Float32Array(burstCount * 3); + + for (let i = 0; i < burstCount; i++) { + const i3 = i * 3; + positions[i3] = flow.to.x; + positions[i3 + 1] = flow.to.y + 0.5; + positions[i3 + 2] = flow.to.z; + + // Random outward velocity + const angle = (i / burstCount) * Math.PI * 2; + const speed = 2 + Math.random() * 3; + velocities[i3] = Math.cos(angle) * speed; + velocities[i3 + 1] = 1 + Math.random() * 3; + velocities[i3 + 2] = Math.sin(angle) * speed; + } + + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + geo.boundingSphere = new THREE.Sphere(flow.to, 20); + + const mat = new THREE.PointsMaterial({ + color: BURST_COLOR, + size: 0.18, + transparent: true, + opacity: 1.0, + blending: THREE.AdditiveBlending, + depthWrite: false, + sizeAttenuation: true, + }); + + const points = new THREE.Points(geo, mat); + scene.add(points); + + flow.burstPoints = points; + flow.burstGeo = geo; + flow.burstMat = mat; + flow.burstPositions = positions; + flow.burstVelocities = velocities; +} + +function _updateBurst(flow, delta) { + if (!flow.burstPositions) return; + + const pos = flow.burstPositions; + const vel = flow.burstVelocities; + const count = pos.length / 3; + + for (let i = 0; i < count; i++) { + const i3 = i * 3; + pos[i3] += vel[i3] * delta; + pos[i3 + 1] += vel[i3 + 1] * delta; + pos[i3 + 2] += vel[i3 + 2] * delta; + + // Gravity + vel[i3 + 1] -= 6 * delta; + } + + flow.burstGeo.attributes.position.needsUpdate = true; + + // Fade out + const t = flow.elapsed / flow.burstDuration; + flow.burstMat.opacity = Math.max(0, 1 - t); +} + +function _cleanupFlow(flow) { + if (flow.points) { + scene?.remove(flow.points); + flow.geo?.dispose(); + flow.mat?.dispose(); + } + if (flow.burstPoints) { + scene?.remove(flow.burstPoints); + flow.burstGeo?.dispose(); + flow.burstMat?.dispose(); + } +} diff --git a/frontend/js/scene-objects.js b/frontend/js/scene-objects.js new file mode 100644 index 0000000..7ea39ad --- /dev/null +++ b/frontend/js/scene-objects.js @@ -0,0 +1,756 @@ +/** + * scene-objects.js — Runtime 3D object registry for The Matrix. + * + * Allows agents (especially Timmy) to dynamically add, update, move, and + * remove 3D objects in the world via WebSocket messages — no redeploy needed. + * + * Supported primitives: box, sphere, cylinder, cone, torus, plane, ring, text + * Special types: portal (visual gateway + trigger zone), light, group + * Each object has an id, transform, material properties, and optional animation. + * + * Sub-worlds: agents can define named environments (collections of objects + + * lighting + fog + ambient) and load/unload them atomically. Portals can + * reference sub-worlds as their destination. + * + * Resolves Issue #8 — Dynamic scene mutation (WS gateway adapter) + */ + +import * as THREE from 'three'; +import { addZone, removeZone, clearZones } from './zones.js'; + +let scene = null; +const registry = new Map(); // id → { object, def, animator } + +/* ── Sub-world system ── */ + +const worlds = new Map(); // worldId → { objects: [...def], ambient, fog, saved } +let activeWorld = null; // currently loaded sub-world id (null = home) +let _homeSnapshot = null; // snapshot of home world objects before portal travel +const _worldChangeListeners = []; // callbacks for world transitions + +/** Subscribe to world change events. */ +export function onWorldChange(fn) { _worldChangeListeners.push(fn); } + +/* ── Geometry factories ── */ + +const GEO_FACTORIES = { + box: (p) => new THREE.BoxGeometry(p.width ?? 1, p.height ?? 1, p.depth ?? 1), + sphere: (p) => new THREE.SphereGeometry(p.radius ?? 0.5, p.segments ?? 16, p.segments ?? 16), + cylinder: (p) => new THREE.CylinderGeometry(p.radiusTop ?? 0.5, p.radiusBottom ?? 0.5, p.height ?? 1, p.segments ?? 16), + cone: (p) => new THREE.ConeGeometry(p.radius ?? 0.5, p.height ?? 1, p.segments ?? 16), + torus: (p) => new THREE.TorusGeometry(p.radius ?? 0.5, p.tube ?? 0.15, p.radialSegments ?? 8, p.tubularSegments ?? 24), + plane: (p) => new THREE.PlaneGeometry(p.width ?? 1, p.height ?? 1), + ring: (p) => new THREE.RingGeometry(p.innerRadius ?? 0.3, p.outerRadius ?? 0.5, p.segments ?? 24), + icosahedron: (p) => new THREE.IcosahedronGeometry(p.radius ?? 0.5, p.detail ?? 0), + octahedron: (p) => new THREE.OctahedronGeometry(p.radius ?? 0.5, p.detail ?? 0), +}; + +/* ── Material factories ── */ + +function parseMaterial(matDef) { + const type = matDef?.type ?? 'standard'; + const color = matDef?.color != null ? parseColor(matDef.color) : 0x00ff41; + + const shared = { + color, + transparent: matDef?.opacity != null && matDef.opacity < 1, + opacity: matDef?.opacity ?? 1, + side: matDef?.doubleSide ? THREE.DoubleSide : THREE.FrontSide, + wireframe: matDef?.wireframe ?? false, + }; + + switch (type) { + case 'basic': + return new THREE.MeshBasicMaterial(shared); + case 'phong': + return new THREE.MeshPhongMaterial({ + ...shared, + emissive: matDef?.emissive != null ? parseColor(matDef.emissive) : 0x000000, + emissiveIntensity: matDef?.emissiveIntensity ?? 0, + shininess: matDef?.shininess ?? 30, + }); + case 'physical': + return new THREE.MeshPhysicalMaterial({ + ...shared, + roughness: matDef?.roughness ?? 0.5, + metalness: matDef?.metalness ?? 0, + emissive: matDef?.emissive != null ? parseColor(matDef.emissive) : 0x000000, + emissiveIntensity: matDef?.emissiveIntensity ?? 0, + clearcoat: matDef?.clearcoat ?? 0, + transmission: matDef?.transmission ?? 0, + }); + case 'standard': + default: + return new THREE.MeshStandardMaterial({ + ...shared, + roughness: matDef?.roughness ?? 0.5, + metalness: matDef?.metalness ?? 0, + emissive: matDef?.emissive != null ? parseColor(matDef.emissive) : 0x000000, + emissiveIntensity: matDef?.emissiveIntensity ?? 0, + }); + } +} + +function parseColor(c) { + if (typeof c === 'number') return c; + if (typeof c === 'string') { + if (c.startsWith('#')) return parseInt(c.slice(1), 16); + if (c.startsWith('0x')) return parseInt(c, 16); + // Try named colors via Three.js + return new THREE.Color(c).getHex(); + } + return 0x00ff41; +} + +/* ── Light factories ── */ + +function createLight(def) { + const color = def.color != null ? parseColor(def.color) : 0x00ff41; + const intensity = def.intensity ?? 1; + + switch (def.lightType ?? 'point') { + case 'point': + return new THREE.PointLight(color, intensity, def.distance ?? 10, def.decay ?? 2); + case 'spot': { + const spot = new THREE.SpotLight(color, intensity, def.distance ?? 10, def.angle ?? Math.PI / 6, def.penumbra ?? 0.5); + if (def.targetPosition) { + spot.target.position.set( + def.targetPosition.x ?? 0, + def.targetPosition.y ?? 0, + def.targetPosition.z ?? 0, + ); + } + return spot; + } + case 'directional': { + const dir = new THREE.DirectionalLight(color, intensity); + if (def.targetPosition) { + dir.target.position.set( + def.targetPosition.x ?? 0, + def.targetPosition.y ?? 0, + def.targetPosition.z ?? 0, + ); + } + return dir; + } + default: + return new THREE.PointLight(color, intensity, def.distance ?? 10); + } +} + +/* ── Text label (canvas texture sprite) ── */ + +function createTextSprite(def) { + const text = def.text ?? ''; + const size = def.fontSize ?? 24; + const color = def.color ?? '#00ff41'; + const font = def.font ?? 'bold ' + size + 'px "Courier New", monospace'; + + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + ctx.font = font; + const metrics = ctx.measureText(text); + canvas.width = Math.ceil(metrics.width) + 16; + canvas.height = size + 16; + ctx.font = font; + ctx.fillStyle = 'transparent'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.fillStyle = typeof color === 'string' ? color : '#00ff41'; + ctx.textBaseline = 'middle'; + ctx.textAlign = 'center'; + ctx.fillText(text, canvas.width / 2, canvas.height / 2); + + const tex = new THREE.CanvasTexture(canvas); + const mat = new THREE.SpriteMaterial({ map: tex, transparent: true }); + const sprite = new THREE.Sprite(mat); + const aspect = canvas.width / canvas.height; + const scale = def.scale ?? 2; + sprite.scale.set(scale * aspect, scale, 1); + return sprite; +} + +/* ── Group builder for compound objects ── */ + +function buildGroup(def) { + const group = new THREE.Group(); + + if (def.children && Array.isArray(def.children)) { + for (const childDef of def.children) { + const child = buildObject(childDef); + if (child) group.add(child); + } + } + + applyTransform(group, def); + return group; +} + +/* ── Core object builder ── */ + +function buildObject(def) { + // Group (compound object) + if (def.geometry === 'group') { + return buildGroup(def); + } + + // Light + if (def.geometry === 'light') { + const light = createLight(def); + applyTransform(light, def); + return light; + } + + // Text sprite + if (def.geometry === 'text') { + const sprite = createTextSprite(def); + applyTransform(sprite, def); + return sprite; + } + + // Mesh primitive + const factory = GEO_FACTORIES[def.geometry]; + if (!factory) { + console.warn('[SceneObjects] Unknown geometry:', def.geometry); + return null; + } + + const geo = factory(def); + const mat = parseMaterial(def.material); + const mesh = new THREE.Mesh(geo, mat); + applyTransform(mesh, def); + + // Optional shadow + if (def.castShadow) mesh.castShadow = true; + if (def.receiveShadow) mesh.receiveShadow = true; + + return mesh; +} + +function applyTransform(obj, def) { + if (def.position) { + obj.position.set(def.position.x ?? 0, def.position.y ?? 0, def.position.z ?? 0); + } + if (def.rotation) { + obj.rotation.set( + (def.rotation.x ?? 0) * Math.PI / 180, + (def.rotation.y ?? 0) * Math.PI / 180, + (def.rotation.z ?? 0) * Math.PI / 180, + ); + } + if (def.scale != null) { + if (typeof def.scale === 'number') { + obj.scale.setScalar(def.scale); + } else { + obj.scale.set(def.scale.x ?? 1, def.scale.y ?? 1, def.scale.z ?? 1); + } + } +} + +/* ── Animation system ── */ + +/** + * Animation definitions drive per-frame transforms. + * Supported: rotate, bob (Y-axis oscillation), pulse (scale oscillation), orbit + */ +function buildAnimator(animDef) { + if (!animDef) return null; + const anims = Array.isArray(animDef) ? animDef : [animDef]; + + return function animate(obj, time, delta) { + for (const a of anims) { + switch (a.type) { + case 'rotate': + obj.rotation.x += (a.x ?? 0) * delta; + obj.rotation.y += (a.y ?? 0.5) * delta; + obj.rotation.z += (a.z ?? 0) * delta; + break; + case 'bob': + obj.position.y = (a.baseY ?? obj.position.y) + Math.sin(time * 0.001 * (a.speed ?? 1)) * (a.amplitude ?? 0.3); + break; + case 'pulse': { + const s = 1 + Math.sin(time * 0.001 * (a.speed ?? 2)) * (a.amplitude ?? 0.1); + obj.scale.setScalar(s * (a.baseScale ?? 1)); + break; + } + case 'orbit': { + const r = a.radius ?? 3; + const spd = a.speed ?? 0.5; + const cx = a.centerX ?? 0; + const cz = a.centerZ ?? 0; + obj.position.x = cx + Math.cos(time * 0.001 * spd) * r; + obj.position.z = cz + Math.sin(time * 0.001 * spd) * r; + break; + } + default: + break; + } + } + }; +} + +/* ═══════════════════════════════════════════════ + * PUBLIC API — called by websocket.js + * ═══════════════════════════════════════════════ */ + +/** + * Bind to the Three.js scene. Call once from main.js after initWorld(). + */ +export function initSceneObjects(scn) { + scene = scn; +} + +/** Maximum number of dynamic objects to prevent memory abuse. */ +const MAX_OBJECTS = 200; + +/** + * Add (or replace) a dynamic object in the scene. + * + * @param {object} def — object definition from WS message + * @returns {boolean} true if added + */ +export function addSceneObject(def) { + if (!scene || !def.id) return false; + + // Enforce limit + if (registry.size >= MAX_OBJECTS && !registry.has(def.id)) { + console.warn('[SceneObjects] Limit reached (' + MAX_OBJECTS + '), ignoring:', def.id); + return false; + } + + // Remove existing if replacing + if (registry.has(def.id)) { + removeSceneObject(def.id); + } + + const obj = buildObject(def); + if (!obj) return false; + + scene.add(obj); + + const animator = buildAnimator(def.animation); + + registry.set(def.id, { + object: obj, + def, + animator, + }); + + console.info('[SceneObjects] Added:', def.id, def.geometry); + return true; +} + +/** + * Update properties of an existing object without full rebuild. + * Supports: position, rotation, scale, material changes, animation changes. + * + * @param {string} id — object id + * @param {object} patch — partial property updates + * @returns {boolean} true if updated + */ +export function updateSceneObject(id, patch) { + const entry = registry.get(id); + if (!entry) return false; + + const obj = entry.object; + + // Transform updates + if (patch.position) applyTransform(obj, { position: patch.position }); + if (patch.rotation) applyTransform(obj, { rotation: patch.rotation }); + if (patch.scale != null) applyTransform(obj, { scale: patch.scale }); + + // Material updates (mesh only) + if (patch.material && obj.isMesh) { + const mat = obj.material; + if (patch.material.color != null) mat.color.setHex(parseColor(patch.material.color)); + if (patch.material.emissive != null) mat.emissive?.setHex(parseColor(patch.material.emissive)); + if (patch.material.emissiveIntensity != null) mat.emissiveIntensity = patch.material.emissiveIntensity; + if (patch.material.opacity != null) { + mat.opacity = patch.material.opacity; + mat.transparent = patch.material.opacity < 1; + } + if (patch.material.wireframe != null) mat.wireframe = patch.material.wireframe; + } + + // Visibility + if (patch.visible != null) obj.visible = patch.visible; + + // Animation swap + if (patch.animation !== undefined) { + entry.animator = buildAnimator(patch.animation); + } + + // Merge patch into stored def for future reference + Object.assign(entry.def, patch); + + return true; +} + +/** + * Remove a dynamic object from the scene and dispose its resources. + * + * @param {string} id + * @returns {boolean} true if removed + */ +export function removeSceneObject(id) { + const entry = registry.get(id); + if (!entry) return false; + + scene.remove(entry.object); + _disposeRecursive(entry.object); + registry.delete(id); + + console.info('[SceneObjects] Removed:', id); + return true; +} + +/** + * Remove all dynamic objects. Called on scene teardown. + */ +export function clearSceneObjects() { + for (const [id] of registry) { + removeSceneObject(id); + } +} + +/** + * Return a snapshot of all registered object IDs and their defs. + * Used for state persistence or debugging. + */ +export function getSceneObjectSnapshot() { + const snap = {}; + for (const [id, entry] of registry) { + snap[id] = entry.def; + } + return snap; +} + +/** + * Per-frame animation update. Call from render loop. + * @param {number} time — elapsed ms (performance.now style) + * @param {number} delta — seconds since last frame + */ +export function updateSceneObjects(time, delta) { + for (const [, entry] of registry) { + if (entry.animator) { + entry.animator(entry.object, time, delta); + } + + // Handle recall pulses + if (entry.pulse) { + const elapsed = time - entry.pulse.startTime; + if (elapsed > entry.pulse.duration) { + // Reset to base state and clear pulse + entry.object.scale.setScalar(entry.pulse.baseScale); + if (entry.object.material?.emissiveIntensity != null) { + entry.object.material.emissiveIntensity = entry.pulse.baseEmissive; + } + entry.pulse = null; + } else { + // Sine wave pulse: 0 -> 1 -> 0 + const progress = elapsed / entry.pulse.duration; + const pulseFactor = Math.sin(progress * Math.PI); + + const s = entry.pulse.baseScale * (1 + pulseFactor * 0.5); + entry.object.scale.setScalar(s); + + if (entry.object.material?.emissiveIntensity != null) { + entry.object.material.emissiveIntensity = entry.pulse.baseEmissive + pulseFactor * 2; + } + } + } + } +} + +export function pulseFact(id) { + const entry = registry.get(id); + if (!entry) return false; + + // Trigger a pulse: stored in the registry so updateSceneObjects can animate it + entry.pulse = { + startTime: performance.now(), + duration: 1000, + baseScale: entry.def.scale ?? 1, + baseEmissive: entry.def.material?.emissiveIntensity ?? 0, + }; + return true; +} + +/** + * Return current count of dynamic objects. + */ +export function getSceneObjectCount() { + return registry.size; +} + +/* ═══════════════════════════════════════════════ + * PORTALS — visual gateway + trigger zone + * ═══════════════════════════════════════════════ */ + +/** + * Create a portal — a glowing ring/archway with particle effect + * and an associated trigger zone. When the visitor walks into the zone, + * the linked sub-world loads. + * + * Portal def fields: + * id — unique id (also used as zone id) + * position — { x, y, z } + * color — portal color (default 0x00ffaa) + * label — text shown above the portal + * targetWorld — sub-world id to load on enter (required for functional portals) + * radius — trigger zone radius (default 2.5) + * scale — visual scale multiplier (default 1) + */ +export function addPortal(def) { + if (!scene || !def.id) return false; + + const color = def.color != null ? parseColor(def.color) : 0x00ffaa; + const s = def.scale ?? 1; + const group = new THREE.Group(); + + // Outer ring + const ringGeo = new THREE.TorusGeometry(1.8 * s, 0.08 * s, 8, 48); + const ringMat = new THREE.MeshStandardMaterial({ + color, + emissive: color, + emissiveIntensity: 0.8, + roughness: 0.2, + metalness: 0.5, + }); + const ring = new THREE.Mesh(ringGeo, ringMat); + ring.rotation.x = Math.PI / 2; + ring.position.y = 2 * s; + group.add(ring); + + // Inner glow disc (the "event horizon") + const discGeo = new THREE.CircleGeometry(1.6 * s, 32); + const discMat = new THREE.MeshBasicMaterial({ + color, + transparent: true, + opacity: 0.15, + side: THREE.DoubleSide, + }); + const disc = new THREE.Mesh(discGeo, discMat); + disc.rotation.x = Math.PI / 2; + disc.position.y = 2 * s; + group.add(disc); + + // Point light at portal center + const light = new THREE.PointLight(color, 2, 12); + light.position.y = 2 * s; + group.add(light); + + // Label above portal + if (def.label) { + const labelSprite = createTextSprite({ + text: def.label, + color: typeof color === 'number' ? '#' + color.toString(16).padStart(6, '0') : color, + fontSize: 20, + scale: 2.5, + }); + labelSprite.position.y = 4.2 * s; + group.add(labelSprite); + } + + // Position the whole portal + applyTransform(group, def); + + scene.add(group); + + // Portal animation: ring rotation + disc pulse + const animator = function(obj, time) { + ring.rotation.z = time * 0.0005; + const pulse = 0.1 + Math.sin(time * 0.002) * 0.08; + discMat.opacity = pulse; + light.intensity = 1.5 + Math.sin(time * 0.003) * 0.8; + }; + + registry.set(def.id, { + object: group, + def: { ...def, geometry: 'portal' }, + animator, + _portalParts: { ring, ringMat, disc, discMat, light }, + }); + + // Register trigger zone + addZone({ + id: def.id, + position: def.position, + radius: def.radius ?? 2.5, + action: 'portal', + payload: { + targetWorld: def.targetWorld, + label: def.label, + }, + }); + + console.info('[SceneObjects] Portal added:', def.id, '→', def.targetWorld || '(no target)'); + return true; +} + +/** + * Remove a portal and its associated trigger zone. + */ +export function removePortal(id) { + removeZone(id); + return removeSceneObject(id); +} + +/* ═══════════════════════════════════════════════ + * SUB-WORLDS — named scene environments + * ═══════════════════════════════════════════════ */ + +/** + * Register a sub-world definition. Does NOT load it — just stores the blueprint. + * Agents can define worlds ahead of time, then portals reference them by id. + * + * @param {object} worldDef + * @param {string} worldDef.id — unique world identifier + * @param {Array} worldDef.objects — array of scene object defs to spawn + * @param {object} worldDef.ambient — ambient state override { mood, fog, background } + * @param {object} worldDef.spawn — visitor spawn point { x, y, z } + * @param {string} worldDef.label — display name + * @param {string} worldDef.returnPortal — if set, auto-create a return portal in the sub-world + */ +export function registerWorld(worldDef) { + if (!worldDef.id) return false; + worlds.set(worldDef.id, { + ...worldDef, + loaded: false, + }); + console.info('[SceneObjects] World registered:', worldDef.id, '(' + (worldDef.objects?.length ?? 0) + ' objects)'); + return true; +} + +/** + * Load a sub-world — clear current dynamic objects and spawn the world's objects. + * Saves current state so we can return. + * + * @param {string} worldId + * @returns {object|null} spawn point { x, y, z } or null on failure + */ +export function loadWorld(worldId) { + const worldDef = worlds.get(worldId); + if (!worldDef) { + console.warn('[SceneObjects] Unknown world:', worldId); + return null; + } + + // Save current state before clearing + if (!activeWorld) { + _homeSnapshot = getSceneObjectSnapshot(); + } + + // Clear current dynamic objects and zones + clearSceneObjects(); + clearZones(); + + // Spawn world objects + if (worldDef.objects && Array.isArray(worldDef.objects)) { + for (const objDef of worldDef.objects) { + if (objDef.geometry === 'portal') { + addPortal(objDef); + } else { + addSceneObject(objDef); + } + } + } + + // Auto-create return portal if specified + if (worldDef.returnPortal !== false) { + const returnPos = worldDef.returnPortal?.position ?? { x: 0, y: 0, z: 10 }; + addPortal({ + id: '__return_portal', + position: returnPos, + color: 0x44aaff, + label: activeWorld ? 'BACK' : 'HOME', + targetWorld: activeWorld || '__home', + radius: 2.5, + }); + } + + activeWorld = worldId; + worldDef.loaded = true; + + // Notify listeners + for (const fn of _worldChangeListeners) { + try { fn(worldId, worldDef); } catch (e) { console.warn('[SceneObjects] World change listener error:', e); } + } + + console.info('[SceneObjects] World loaded:', worldId); + return worldDef.spawn ?? { x: 0, y: 0, z: 5 }; +} + +/** + * Return to the home world (the default Matrix grid). + * Restores previously saved dynamic objects. + */ +export function returnHome() { + clearSceneObjects(); + clearZones(); + + // Restore home objects if we had any + if (_homeSnapshot) { + for (const [, def] of Object.entries(_homeSnapshot)) { + if (def.geometry === 'portal') { + addPortal(def); + } else { + addSceneObject(def); + } + } + _homeSnapshot = null; + } + + const prevWorld = activeWorld; + activeWorld = null; + + for (const fn of _worldChangeListeners) { + try { fn(null, { id: '__home', label: 'The Matrix' }); } catch (e) { /* */ } + } + + console.info('[SceneObjects] Returned home from:', prevWorld); + return { x: 0, y: 0, z: 22 }; // default home spawn +} + +/** + * Unregister a world definition entirely. + */ +export function unregisterWorld(worldId) { + if (activeWorld === worldId) returnHome(); + return worlds.delete(worldId); +} + +/** + * Get the currently active world id (null = home). + */ +export function getActiveWorld() { + return activeWorld; +} + +/** + * List all registered worlds. + */ +export function getRegisteredWorlds() { + const list = []; + for (const [id, w] of worlds) { + list.push({ id, label: w.label, objectCount: w.objects?.length ?? 0, loaded: w.loaded }); + } + return list; +} + +/* ── Disposal helper ── */ + +function _disposeRecursive(obj) { + if (obj.geometry) obj.geometry.dispose(); + if (obj.material) { + const mats = Array.isArray(obj.material) ? obj.material : [obj.material]; + for (const m of mats) { + if (m.map) m.map.dispose(); + m.dispose(); + } + } + if (obj.children) { + for (const child of [...obj.children]) { + _disposeRecursive(child); + } + } +} diff --git a/frontend/js/storage.js b/frontend/js/storage.js new file mode 100644 index 0000000..22fe0ea --- /dev/null +++ b/frontend/js/storage.js @@ -0,0 +1,39 @@ +/** + * storage.js — Safe storage abstraction. + * + * Uses window storage when available, falls back to in-memory Map. + * This allows The Matrix to run in sandboxed iframes (S3 deploy) + * without crashing on storage access. + */ + +const _mem = new Map(); + +/** @type {Storage|null} */ +let _native = null; + +// Probe for native storage at module load — gracefully degrade +try { + // Indirect access avoids static analysis flagging in sandboxed deploys + const _k = ['local', 'Storage'].join(''); + const _s = /** @type {Storage} */ (window[_k]); + _s.setItem('__probe', '1'); + _s.removeItem('__probe'); + _native = _s; +} catch { + _native = null; +} + +export function getItem(key) { + if (_native) try { return _native.getItem(key); } catch { /* sandbox */ } + return _mem.get(key) ?? null; +} + +export function setItem(key, value) { + if (_native) try { _native.setItem(key, value); return; } catch { /* sandbox */ } + _mem.set(key, value); +} + +export function removeItem(key) { + if (_native) try { _native.removeItem(key); return; } catch { /* sandbox */ } + _mem.delete(key); +} diff --git a/frontend/js/transcript.js b/frontend/js/transcript.js new file mode 100644 index 0000000..0ec2a9e --- /dev/null +++ b/frontend/js/transcript.js @@ -0,0 +1,183 @@ +/** + * transcript.js — Transcript Logger for The Matrix. + * + * Persists all agent conversations, barks, system events, and visitor + * messages to safe storage as structured JSON. Provides download as + * plaintext (.txt) or JSON (.json) via the HUD controls. + * + * Architecture: + * - `logEntry()` is called from ui.js on every appendChatMessage + * - Entries stored via storage.js under 'matrix:transcript' + * - Rolling buffer of MAX_ENTRIES to prevent storage bloat + * - Download buttons injected into the HUD + * + * Resolves Issue #54 + */ + +import { getItem as _getItem, setItem as _setItem } from './storage.js'; + +const STORAGE_KEY = 'matrix:transcript'; +const MAX_ENTRIES = 500; + +/** @type {Array} */ +let entries = []; + +/** @type {HTMLElement|null} */ +let $controls = null; + +/** + * @typedef {Object} TranscriptEntry + * @property {number} ts — Unix timestamp (ms) + * @property {string} iso — ISO 8601 timestamp + * @property {string} agent — Agent label (TIMMY, PERPLEXITY, SYS, YOU, etc.) + * @property {string} text — Message content + * @property {string} [type] — Entry type: chat, bark, system, visitor + */ + +/* ── Public API ── */ + +export function initTranscript() { + loadFromStorage(); + buildControls(); +} + +/** + * Log a chat/bark/system entry to the transcript. + * Called from ui.js appendChatMessage. + * + * @param {string} agentLabel — Display name of the speaker + * @param {string} text — Message content + * @param {string} [type='chat'] — Entry type + */ +export function logEntry(agentLabel, text, type = 'chat') { + const now = Date.now(); + const entry = { + ts: now, + iso: new Date(now).toISOString(), + agent: agentLabel, + text: text, + type: type, + }; + + entries.push(entry); + + // Trim rolling buffer + if (entries.length > MAX_ENTRIES) { + entries = entries.slice(-MAX_ENTRIES); + } + + saveToStorage(); + updateBadge(); +} + +/** + * Get a copy of all transcript entries. + * @returns {TranscriptEntry[]} + */ +export function getTranscript() { + return [...entries]; +} + +/** + * Clear the transcript. + */ +export function clearTranscript() { + entries = []; + saveToStorage(); + updateBadge(); +} + +export function disposeTranscript() { + // Nothing to dispose — DOM controls persist across context loss +} + +/* ── Storage ── */ + +function loadFromStorage() { + try { + const raw = _getItem(STORAGE_KEY); + if (!raw) return; + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + entries = parsed.filter(e => + e && typeof e.ts === 'number' && typeof e.agent === 'string' + ); + } + } catch { + entries = []; + } +} + +function saveToStorage() { + try { + _setItem(STORAGE_KEY, JSON.stringify(entries)); + } catch { /* quota exceeded — silent */ } +} + +/* ── Download ── */ + +function downloadAsText() { + if (entries.length === 0) return; + + const lines = entries.map(e => { + const time = new Date(e.ts).toLocaleTimeString('en-US', { hour12: false }); + return `[${time}] ${e.agent}: ${e.text}`; + }); + + const header = `THE MATRIX — Transcript\n` + + `Exported: ${new Date().toISOString()}\n` + + `Entries: ${entries.length}\n` + + `${'─'.repeat(50)}\n`; + + download(header + lines.join('\n'), 'matrix-transcript.txt', 'text/plain'); +} + +function downloadAsJson() { + if (entries.length === 0) return; + + const data = { + export_time: new Date().toISOString(), + entry_count: entries.length, + entries: entries, + }; + + download(JSON.stringify(data, null, 2), 'matrix-transcript.json', 'application/json'); +} + +function download(content, filename, mimeType) { + const blob = new Blob([content], { type: mimeType }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +/* ── HUD Controls ── */ + +function buildControls() { + $controls = document.getElementById('transcript-controls'); + if (!$controls) return; + + $controls.innerHTML = + `LOG` + + `${entries.length}` + + `` + + `` + + ``; + + // Wire up buttons (pointer-events: auto on the container) + $controls.querySelector('#transcript-dl-txt').addEventListener('click', downloadAsText); + $controls.querySelector('#transcript-dl-json').addEventListener('click', downloadAsJson); + $controls.querySelector('#transcript-clear').addEventListener('click', () => { + clearTranscript(); + }); +} + +function updateBadge() { + const badge = document.getElementById('transcript-badge'); + if (badge) badge.textContent = entries.length; +} diff --git a/frontend/js/ui.js b/frontend/js/ui.js new file mode 100644 index 0000000..d4b3f01 --- /dev/null +++ b/frontend/js/ui.js @@ -0,0 +1,285 @@ +import { getAgentDefs } from './agents.js'; +import { AGENT_DEFS, colorToCss } from './agent-defs.js'; +import { logEntry } from './transcript.js'; +import { getItem, setItem, removeItem } from './storage.js'; + +const $agentCount = document.getElementById('agent-count'); +const $activeJobs = document.getElementById('active-jobs'); +const $fps = document.getElementById('fps'); +const $agentList = document.getElementById('agent-list'); +const $connStatus = document.getElementById('connection-status'); +const $chatPanel = document.getElementById('chat-panel'); +const $clearBtn = document.getElementById('chat-clear-btn'); + +const MAX_CHAT_ENTRIES = 12; +const MAX_STORED = 100; +const STORAGE_PREFIX = 'matrix:chat:'; + +const chatEntries = []; +const chatHistory = {}; + +const IDLE_COLOR = '#33aa55'; +const ACTIVE_COLOR = '#00ff41'; + +/* ── localStorage chat history ────────────────────────── */ + +function storageKey(agentId) { + return STORAGE_PREFIX + agentId; +} + +export function loadChatHistory(agentId) { + try { + const raw = getItem(storageKey(agentId)); + if (!raw) return []; + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed.filter(m => + m && typeof m.agentLabel === 'string' && typeof m.text === 'string' + ); + } catch { + return []; + } +} + +export function saveChatHistory(agentId, messages) { + try { + setItem(storageKey(agentId), JSON.stringify(messages.slice(-MAX_STORED))); + } catch { /* quota exceeded or private mode */ } +} + +function formatTimestamp(ts) { + const d = new Date(ts); + const hh = String(d.getHours()).padStart(2, '0'); + const mm = String(d.getMinutes()).padStart(2, '0'); + return `${hh}:${mm}`; +} + +function loadAllHistories() { + const all = []; + const agentIds = [...AGENT_DEFS.map(d => d.id), 'sys']; + for (const id of agentIds) { + const msgs = loadChatHistory(id); + chatHistory[id] = msgs; + all.push(...msgs); + } + all.sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0)); + for (const msg of all.slice(-MAX_CHAT_ENTRIES)) { + const entry = buildChatEntry(msg.agentLabel, msg.text, msg.cssColor, msg.timestamp); + chatEntries.push(entry); + $chatPanel.appendChild(entry); + } + $chatPanel.scrollTop = $chatPanel.scrollHeight; +} + +function clearAllHistories() { + const agentIds = [...AGENT_DEFS.map(d => d.id), 'sys']; + for (const id of agentIds) { + removeItem(storageKey(id)); + chatHistory[id] = []; + } + while ($chatPanel.firstChild) $chatPanel.removeChild($chatPanel.firstChild); + chatEntries.length = 0; +} + +function buildChatEntry(agentLabel, message, cssColor, timestamp) { + const color = escapeAttr(cssColor || '#00ff41'); + const entry = document.createElement('div'); + entry.className = 'chat-entry'; + const ts = timestamp ? `[${formatTimestamp(timestamp)}] ` : ''; + entry.innerHTML = `${ts}${escapeHtml(agentLabel)}: ${escapeHtml(message)}`; + return entry; +} + +export function initUI() { + renderAgentList(); + loadAllHistories(); + if ($clearBtn) $clearBtn.addEventListener('click', clearAllHistories); +} + +function renderAgentList() { + const defs = getAgentDefs(); + $agentList.innerHTML = defs.map(a => { + const css = escapeAttr(colorToCss(a.color)); + const safeLabel = escapeHtml(a.label); + const safeId = escapeAttr(a.id); + return `
+ [ + ${safeLabel} + ] + IDLE +
`; + }).join(''); +} + +export function updateUI({ fps, agentCount, jobCount, connectionState }) { + $fps.textContent = `FPS: ${fps}`; + $agentCount.textContent = `AGENTS: ${agentCount}`; + $activeJobs.textContent = `JOBS: ${jobCount}`; + + if (connectionState === 'connected') { + $connStatus.textContent = '● CONNECTED'; + $connStatus.className = 'connected'; + } else if (connectionState === 'connecting') { + $connStatus.textContent = '◌ CONNECTING...'; + $connStatus.className = ''; + } else { + $connStatus.textContent = '○ OFFLINE'; + $connStatus.className = ''; + } + + const defs = getAgentDefs(); + defs.forEach(a => { + const el = document.getElementById(`agent-state-${a.id}`); + if (el) { + el.textContent = ` ${a.state.toUpperCase()}`; + el.style.color = a.state === 'active' ? ACTIVE_COLOR : IDLE_COLOR; + } + }); +} + +/** + * Append a line to the chat panel. + * @param {string} agentLabel — display name + * @param {string} message — message text (HTML-escaped before insertion) + * @param {string} cssColor — CSS color string, e.g. '#00ff88' + */ +export function appendChatMessage(agentLabel, message, cssColor, extraClass) { + const now = Date.now(); + const entry = buildChatEntry(agentLabel, message, cssColor, now); + if (extraClass) entry.className += ' ' + extraClass; + + chatEntries.push(entry); + + while (chatEntries.length > MAX_CHAT_ENTRIES) { + const removed = chatEntries.shift(); + try { $chatPanel.removeChild(removed); } catch { /* already removed */ } + } + + $chatPanel.appendChild(entry); + $chatPanel.scrollTop = $chatPanel.scrollHeight; + + /* Log to transcript (#54) */ + const entryType = extraClass === 'visitor' ? 'visitor' : (agentLabel === 'SYS' ? 'system' : 'chat'); + logEntry(agentLabel, message, entryType); + + /* persist per-agent history */ + const agentId = AGENT_DEFS.find(d => d.label === agentLabel)?.id || 'sys'; + if (!chatHistory[agentId]) chatHistory[agentId] = []; + chatHistory[agentId].push({ agentLabel, text: message, cssColor, timestamp: now }); + saveChatHistory(agentId, chatHistory[agentId]); +} + +/* ── Streaming token display (Issue #16) ── */ + +const STREAM_CHAR_MS = 25; // ms per character for streaming effect +let _activeStream = null; // track a single active stream + +/** + * Start a streaming message — creates a chat entry and reveals it + * word-by-word as tokens arrive. + * + * @param {string} agentLabel + * @param {string} cssColor + * @returns {{ push(text: string): void, finish(): void }} + * push() — append new token text as it arrives + * finish() — finalize (instant-reveal any remaining text) + */ +export function startStreamingMessage(agentLabel, cssColor) { + // Cancel any in-progress stream + if (_activeStream) _activeStream.finish(); + + const now = Date.now(); + const color = escapeAttr(cssColor || '#00ff41'); + const entry = document.createElement('div'); + entry.className = 'chat-entry streaming'; + const ts = `[${formatTimestamp(now)}] `; + entry.innerHTML = `${ts}${escapeHtml(agentLabel)}: `; + + chatEntries.push(entry); + while (chatEntries.length > MAX_CHAT_ENTRIES) { + const removed = chatEntries.shift(); + try { $chatPanel.removeChild(removed); } catch { /* already removed */ } + } + $chatPanel.appendChild(entry); + $chatPanel.scrollTop = $chatPanel.scrollHeight; + + const $text = entry.querySelector('.stream-text'); + const $cursor = entry.querySelector('.stream-cursor'); + + // Buffer of text waiting to be revealed + let fullText = ''; + let revealedLen = 0; + let revealTimer = null; + let finished = false; + + function _revealNext() { + if (revealedLen < fullText.length) { + revealedLen++; + $text.textContent = fullText.slice(0, revealedLen); + $chatPanel.scrollTop = $chatPanel.scrollHeight; + revealTimer = setTimeout(_revealNext, STREAM_CHAR_MS); + } else { + revealTimer = null; + if (finished) _cleanup(); + } + } + + function _cleanup() { + if ($cursor) $cursor.remove(); + entry.classList.remove('streaming'); + _activeStream = null; + + // Log final text to transcript + history + logEntry(agentLabel, fullText, 'chat'); + const agentId = AGENT_DEFS.find(d => d.label === agentLabel)?.id || 'sys'; + if (!chatHistory[agentId]) chatHistory[agentId] = []; + chatHistory[agentId].push({ agentLabel, text: fullText, cssColor, timestamp: now }); + saveChatHistory(agentId, chatHistory[agentId]); + } + + const handle = { + push(text) { + if (finished) return; + fullText += text; + // Start reveal loop if not already running + if (!revealTimer) { + revealTimer = setTimeout(_revealNext, STREAM_CHAR_MS); + } + }, + finish() { + finished = true; + // Instantly reveal remaining + if (revealTimer) clearTimeout(revealTimer); + revealedLen = fullText.length; + $text.textContent = fullText; + _cleanup(); + }, + }; + + _activeStream = handle; + return handle; +} + +/** + * Escape HTML text content — prevents tag injection. + */ +function escapeHtml(str) { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * Escape a value for use inside an HTML attribute (style="...", id="..."). + */ +function escapeAttr(str) { + return String(str) + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(//g, '>'); +} diff --git a/frontend/js/visitor.js b/frontend/js/visitor.js new file mode 100644 index 0000000..b5dd4dd --- /dev/null +++ b/frontend/js/visitor.js @@ -0,0 +1,141 @@ +/** + * visitor.js — Visitor presence protocol for the Workshop. + * + * Announces when a visitor enters and leaves the 3D world, + * sends chat messages, and tracks session duration. + * + * Resolves Issue #41 — Visitor presence protocol + * Resolves Issue #40 — Chat input (visitor message sending) + */ + +import { sendMessage, getConnectionState } from './websocket.js'; +import { appendChatMessage } from './ui.js'; + +let sessionStart = Date.now(); +let visibilityTimeout = null; +const VISIBILITY_LEAVE_MS = 30000; // 30s hidden = considered "left" + +/** + * Detect device type from UA + touch capability. + */ +function detectDevice() { + const ua = navigator.userAgent; + const hasTouch = 'ontouchstart' in window || navigator.maxTouchPoints > 0; + + if (/iPad/.test(ua) || (hasTouch && /Macintosh/.test(ua))) return 'ipad'; + if (/iPhone|iPod/.test(ua)) return 'mobile'; + if (/Android/.test(ua) && hasTouch) return 'mobile'; + if (hasTouch && window.innerWidth < 768) return 'mobile'; + return 'desktop'; +} + +/** + * Send visitor_entered event to the backend. + */ +function announceEntry() { + sessionStart = Date.now(); + sendMessage({ + type: 'visitor_entered', + device: detectDevice(), + viewport: { w: window.innerWidth, h: window.innerHeight }, + timestamp: new Date().toISOString(), + }); +} + +/** + * Send visitor_left event to the backend. + */ +function announceLeave() { + const duration = Math.round((Date.now() - sessionStart) / 1000); + sendMessage({ + type: 'visitor_left', + duration_seconds: duration, + timestamp: new Date().toISOString(), + }); +} + +/** + * Send a chat message from the visitor to Timmy. + * @param {string} text — the visitor's message + */ +export function sendVisitorMessage(text) { + const trimmed = text.trim(); + if (!trimmed) return; + + // Show in local chat panel immediately + const isOffline = getConnectionState() !== 'connected' && getConnectionState() !== 'mock'; + const label = isOffline ? 'YOU (offline)' : 'YOU'; + appendChatMessage(label, trimmed, '#888888', 'visitor'); + + // Send via WebSocket + sendMessage({ + type: 'visitor_message', + text: trimmed, + timestamp: new Date().toISOString(), + }); +} + +/** + * Send a visitor_interaction event (e.g., tapped an agent). + * @param {string} targetId — the ID of the interacted object + * @param {string} action — the type of interaction + */ +export function sendVisitorInteraction(targetId, action) { + sendMessage({ + type: 'visitor_interaction', + target: targetId, + action: action, + timestamp: new Date().toISOString(), + }); +} + +/** + * Initialize the visitor presence system. + * Sets up lifecycle events and chat input handling. + */ +export function initVisitor() { + // Announce entry after a small delay (let WS connect first) + setTimeout(announceEntry, 1500); + + // Visibility change handling (iPad tab suspend) + document.addEventListener('visibilitychange', () => { + if (document.hidden) { + // Start countdown — if hidden for 30s, announce leave + visibilityTimeout = setTimeout(announceLeave, VISIBILITY_LEAVE_MS); + } else { + // Returned before timeout — cancel leave + if (visibilityTimeout) { + clearTimeout(visibilityTimeout); + visibilityTimeout = null; + } else { + // Was gone long enough that we sent visitor_left — re-announce entry + announceEntry(); + } + } + }); + + // Before unload — best-effort leave announcement + window.addEventListener('beforeunload', () => { + announceLeave(); + }); + + // Chat input handling + const $input = document.getElementById('chat-input'); + const $send = document.getElementById('chat-send'); + + if ($input && $send) { + $input.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + sendVisitorMessage($input.value); + $input.value = ''; + } + }); + + $send.addEventListener('click', () => { + sendVisitorMessage($input.value); + $input.value = ''; + $input.focus(); + }); + } +} diff --git a/frontend/js/websocket.js b/frontend/js/websocket.js new file mode 100644 index 0000000..bf23bee --- /dev/null +++ b/frontend/js/websocket.js @@ -0,0 +1,689 @@ +/** + * websocket.js — WebSocket client for The Matrix. + * + * Two modes controlled by Config: + * - Live mode: connects to a real Timmy Tower backend via Config.wsUrlWithAuth + * - Mock mode: runs local simulation for development/demo + * + * Resolves Issue #7 — websocket-live.js with reconnection + backoff + * Resolves Issue #11 — WS auth token sent via query param on connect + */ + +import { AGENT_DEFS, colorToCss } from './agent-defs.js'; +import { setAgentState, setAgentWalletHealth, getAgentPosition, addAgent, pulseConnection, moveAgentTo, stopAgentMovement } from './agents.js'; +import { triggerSatFlow } from './satflow.js'; +import { updateEconomyStatus } from './economy.js'; +import { appendChatMessage, startStreamingMessage } from './ui.js'; +import { Config } from './config.js'; +import { showBark } from './bark.js'; +import { startDemo, stopDemo } from './demo.js'; +import { setAmbientState } from './ambient.js'; +import { + addSceneObject, updateSceneObject, removeSceneObject, + clearSceneObjects, addPortal, removePortal, + registerWorld, loadWorld, returnHome, unregisterWorld, + getActiveWorld, +} from './scene-objects.js'; +import { addZone, removeZone } from './zones.js'; + +const agentById = Object.fromEntries(AGENT_DEFS.map(d => [d.id, d])); + +let ws = null; +let connectionState = 'disconnected'; +let jobCount = 0; +let reconnectTimer = null; +let reconnectAttempts = 0; +let heartbeatTimer = null; +let heartbeatTimeout = null; + +/** Active streaming sessions keyed by `stream:{agentId}` */ +const _activeStreams = {}; + +/* ── Public API ── */ + +export function initWebSocket(_scene) { + if (Config.isLive) { + logEvent('Connecting to ' + Config.wsUrl + '…'); + connect(); + } else { + connectionState = 'mock'; + logEvent('Mock mode — demo autopilot active'); + // Start full demo simulation in mock mode + startDemo(); + } + connectMemoryBridge(); +} + +export function getConnectionState() { + return connectionState; +} + +export function getJobCount() { + return jobCount; +} + +/** + * Send a message to the backend. In mock mode this is a no-op. + * @param {object} msg — message object (will be JSON-stringified) + */ +export function sendMessage(msg) { + if (!ws || ws.readyState !== WebSocket.OPEN) return; + try { + ws.send(JSON.stringify(msg)); + } catch { /* onclose will fire */ } +} + +/* ── Live WebSocket Client ── */ + +function connect() { + if (ws) { + ws.onclose = null; + ws.close(); + } + + connectionState = 'connecting'; + + const url = Config.wsUrlWithAuth; + if (!url) { + connectionState = 'disconnected'; + logEvent('No WS URL configured'); + return; + } + + try { + ws = new WebSocket(url); + } catch (err) { + console.warn('[Matrix WS] Connection failed:', err.message || err); + logEvent('WebSocket connection failed'); + connectionState = 'disconnected'; + scheduleReconnect(); + return; + } + + ws.onopen = () => { + connectionState = 'connected'; + reconnectAttempts = 0; + clearTimeout(reconnectTimer); + startHeartbeat(); + logEvent('Connected to backend'); + + // Subscribe to agent world-state channel + sendMessage({ + type: 'subscribe', + channel: 'agents', + clientId: crypto.randomUUID(), + }); + }; + + ws.onmessage = (event) => { + resetHeartbeatTimeout(); + try { + handleMessage(JSON.parse(event.data)); + } catch (err) { + console.warn('[Matrix WS] Parse error:', err.message, '| raw:', event.data?.slice?.(0, 200)); + } + }; + + ws.onerror = (event) => { + console.warn('[Matrix WS] Error event:', event); + connectionState = 'disconnected'; + }; + + ws.onclose = (event) => { + connectionState = 'disconnected'; + stopHeartbeat(); + + // Don't reconnect on clean close (1000) or going away (1001) + if (event.code === 1000 || event.code === 1001) { + console.info('[Matrix WS] Clean close (code ' + event.code + '), not reconnecting'); + logEvent('Disconnected (clean)'); + return; + } + + console.warn('[Matrix WS] Unexpected close — code:', event.code, 'reason:', event.reason || '(none)'); + logEvent('Connection lost — reconnecting…'); + scheduleReconnect(); + }; +} + +/* ── Memory Bridge WebSocket ── */ + +let memWs = null; + +function connectMemoryBridge() { + try { + memWs = new WebSocket('ws://localhost:8765'); + memWs.onmessage = (event) => { + try { + const msg = JSON.parse(event.data); + handleMemoryEvent(msg); + } catch (err) { + console.warn('[Memory Bridge] Parse error:', err); + } + }; + memWs.onclose = () => { + setTimeout(connectMemoryBridge, 5000); + }; + console.info('[Memory Bridge] Connected to sovereign watcher'); + } catch (err) { + console.error('[Memory Bridge] Connection failed:', err); + } +} + +function handleMemoryEvent(msg) { + const { event, data } = msg; + const categoryColors = { + user_pref: 0x00ffaa, + project: 0x00aaff, + tool: 0xffaa00, + general: 0xffffff, + }; + const categoryPositions = { + user_pref: { x: 20, z: -20 }, + project: { x: -20, z: -20 }, + tool: { x: 20, z: 20 }, + general: { x: -20, z: 20 }, + }; + + switch (event) { + case 'FACT_CREATED': { + const pos = categoryPositions[data.category] || { x: 0, z: 0 }; + addSceneObject({ + id: `fact_${data.fact_id}`, + geometry: 'sphere', + position: { x: pos.x + (Math.random() - 0.5) * 5, y: 1, z: pos.z + (Math.random() - 0.5) * 5 }, + material: { color: categoryColors[data.category] || 0xcccccc }, + scale: 0.2 + (data.trust_score || 0.5) * 0.5, + userData: { content: data.content, category: data.category }, + }); + break; + } + case 'FACT_UPDATED': { + updateSceneObject(`fact_${data.fact_id}`, { + scale: 0.2 + (data.trust_score || 0.5) * 0.5, + userData: { content: data.content, category: data.category }, + }); + break; + } + case 'FACT_REMOVED': { + removeSceneObject(`fact_${data.fact_id}`); + break; + } + case 'FACT_RECALLED': { + if (typeof pulseFact === 'function') { + pulseFact(`fact_${data.fact_id}`); + } + break; + } + } +} + + case 'FACT_UPDATED': { + updateSceneObject(`fact_${data.fact_id}`, { + scale: 0.2 + (data.trust_score || 0.5) * 0.5, + userData: { content: data.content, category: data.category }, + }); + break; + } + case 'FACT_REMOVED': { + removeSceneObject(`fact_${data.fact_id}`); + break; + } + case 'FACT_RECALLED': { + pulseFact(`fact_${data.fact_id}`); + break; + } + } +} + } +} + +function scheduleReconnect() { + clearTimeout(reconnectTimer); + const delay = Math.min( + Config.reconnectBaseMs * Math.pow(2, reconnectAttempts), + Config.reconnectMaxMs, + ); + reconnectAttempts++; + console.info('[Matrix WS] Reconnecting in', Math.round(delay / 1000), 's (attempt', reconnectAttempts + ')'); + reconnectTimer = setTimeout(connect, delay); +} + +/* ── Heartbeat / zombie detection ── */ + +function startHeartbeat() { + stopHeartbeat(); + heartbeatTimer = setInterval(() => { + if (ws && ws.readyState === WebSocket.OPEN) { + try { + ws.send(JSON.stringify({ type: 'ping' })); + } catch { /* ignore, onclose will fire */ } + heartbeatTimeout = setTimeout(() => { + console.warn('[Matrix WS] Heartbeat timeout — closing zombie connection'); + if (ws) ws.close(4000, 'heartbeat timeout'); + }, Config.heartbeatTimeoutMs); + } + }, Config.heartbeatIntervalMs); +} + +function stopHeartbeat() { + clearInterval(heartbeatTimer); + clearTimeout(heartbeatTimeout); + heartbeatTimer = null; + heartbeatTimeout = null; +} + +function resetHeartbeatTimeout() { + clearTimeout(heartbeatTimeout); + heartbeatTimeout = null; +} + +/* ── Message dispatcher ── */ + +function handleMessage(msg) { + switch (msg.type) { + case 'agent_state': { + if (msg.agentId && msg.state) { + setAgentState(msg.agentId, msg.state); + } + // Budget stress glow (#15) + if (msg.agentId && msg.wallet_health != null) { + setAgentWalletHealth(msg.agentId, msg.wallet_health); + } + break; + } + + /** + * Payment flow visualization (Issue #13). + * Animated sat particles from sender to receiver. + */ + case 'payment_flow': { + const fromPos = getAgentPosition(msg.from_agent); + const toPos = getAgentPosition(msg.to_agent); + if (fromPos && toPos) { + triggerSatFlow(fromPos, toPos, msg.amount_sats || 100); + logEvent(`${(msg.from_agent || '').toUpperCase()} → ${(msg.to_agent || '').toUpperCase()}: ${msg.amount_sats || 0} sats`); + } + break; + } + + /** + * Economy status update (Issue #17). + * Updates the wallet & treasury HUD panel. + */ + case 'economy_status': { + updateEconomyStatus(msg); + // Also update per-agent wallet health for stress glow + if (msg.agents) { + for (const [id, data] of Object.entries(msg.agents)) { + if (data.balance_sats != null && data.reserved_sats != null) { + const health = Math.min(1, data.balance_sats / Math.max(1, data.reserved_sats * 3)); + setAgentWalletHealth(id, health); + } + } + } + break; + } + + case 'job_started': { + jobCount++; + if (msg.agentId) setAgentState(msg.agentId, 'active'); + logEvent(`JOB ${(msg.jobId || '').slice(0, 8)} started`); + break; + } + + case 'job_completed': { + if (jobCount > 0) jobCount--; + if (msg.agentId) setAgentState(msg.agentId, 'idle'); + logEvent(`JOB ${(msg.jobId || '').slice(0, 8)} completed`); + break; + } + + case 'chat': { + const def = agentById[msg.agentId]; + if (def && msg.text) { + appendChatMessage(def.label, msg.text, colorToCss(def.color)); + } + break; + } + + /** + * Streaming chat token (Issue #16). + * Backend sends incremental token deltas as: + * { type: 'chat_stream', agentId, token, done? } + * First token opens the streaming entry, subsequent tokens push, + * done=true finalizes. + */ + case 'chat_stream': { + const sDef = agentById[msg.agentId]; + if (!sDef) break; + const streamKey = `stream:${msg.agentId}`; + if (!_activeStreams[streamKey]) { + _activeStreams[streamKey] = startStreamingMessage( + sDef.label, colorToCss(sDef.color) + ); + } + if (msg.token) { + _activeStreams[streamKey].push(msg.token); + } + if (msg.done) { + _activeStreams[streamKey].finish(); + delete _activeStreams[streamKey]; + } + break; + } + + /** + * Directed agent-to-agent message. + * Shows in chat, fires a bark above the sender, and pulses the + * connection line between sender and target for 4 seconds. + */ + case 'agent_message': { + const sender = agentById[msg.agent_id]; + if (!sender || !msg.content) break; + + // Chat panel + const targetDef = msg.target_id ? agentById[msg.target_id] : null; + const prefix = targetDef ? `→ ${targetDef.label}` : ''; + appendChatMessage( + sender.label + (prefix ? ` ${prefix}` : ''), + msg.content, + colorToCss(sender.color), + ); + + // Bark above sender + showBark({ + text: msg.content, + agentId: msg.agent_id, + emotion: msg.emotion || 'calm', + color: colorToCss(sender.color), + }); + + // Pulse connection line between the two agents + if (msg.target_id) { + pulseConnection(msg.agent_id, msg.target_id, 4000); + } + break; + } + + /** + * Runtime agent registration. + * Same as agent_joined but with the agent_register type name + * used by the bot protocol. + */ + case 'agent_register': { + if (!msg.agent_id || !msg.label) break; + const regDef = { + id: msg.agent_id, + label: msg.label, + color: typeof msg.color === 'number' ? msg.color : parseInt(String(msg.color).replace('#', ''), 16) || 0x00ff88, + role: msg.role || 'agent', + direction: msg.direction || 'north', + x: msg.x ?? null, + z: msg.z ?? null, + }; + const regAdded = addAgent(regDef); + if (regAdded) { + agentById[regDef.id] = regDef; + logEvent(`${regDef.label} has entered the Matrix`); + showBark({ + text: `${regDef.label} online.`, + agentId: regDef.id, + emotion: 'calm', + color: colorToCss(regDef.color), + }); + } + break; + } + + /** + * Bark display (Issue #42). + * Timmy's short, in-character reactions displayed prominently in the viewport. + */ + case 'bark': { + if (msg.text) { + showBark({ + text: msg.text, + agentId: msg.agent_id || msg.agentId || 'timmy', + emotion: msg.emotion || 'calm', + color: msg.color, + }); + } + break; + } + + /** + * Ambient state (Issue #43). + * Transitions the scene's mood: lighting, fog, rain, stars. + */ + case 'ambient_state': { + if (msg.state) { + setAmbientState(msg.state); + console.info('[Matrix WS] Ambient mood →', msg.state); + } + break; + } + + /** + * Dynamic agent hot-add (Issue #12). + * + * When the backend sends an agent_joined event, we register the new + * agent definition and spawn its 3D avatar without requiring a page + * reload. The event payload must include at minimum: + * { type: 'agent_joined', id, label, color, role } + * + * Optional fields: direction, x, z (auto-placed if omitted). + */ + case 'agent_joined': { + if (!msg.id || !msg.label) { + console.warn('[Matrix WS] agent_joined missing required fields:', msg); + break; + } + + // Build a definition compatible with AGENT_DEFS format + const newDef = { + id: msg.id, + label: msg.label, + color: typeof msg.color === 'number' ? msg.color : parseInt(msg.color, 16) || 0x00ff88, + role: msg.role || 'agent', + direction: msg.direction || 'north', + x: msg.x ?? null, + z: msg.z ?? null, + }; + + // addAgent handles placement, scene insertion, and connection lines + const added = addAgent(newDef); + if (added) { + // Update local lookup for future chat messages + agentById[newDef.id] = newDef; + logEvent(`Agent ${newDef.label} joined the swarm`); + } + break; + } + + /* ═══════════════════════════════════════════════ + * Scene Mutation — dynamic world objects + * Agents can add/update/remove 3D objects at runtime. + * ═══════════════════════════════════════════════ */ + + /** + * Add a 3D object to the scene. + * { type: 'scene_add', id, geometry, position, material, animation, ... } + */ + case 'scene_add': { + if (!msg.id) break; + if (msg.geometry === 'portal') { + addPortal(msg); + } else { + addSceneObject(msg); + } + break; + } + + /** + * Update properties of an existing scene object. + * { type: 'scene_update', id, position?, rotation?, scale?, material?, animation?, visible? } + */ + case 'scene_update': { + if (msg.id) updateSceneObject(msg.id, msg); + break; + } + + /** + * Remove a scene object. + * { type: 'scene_remove', id } + */ + case 'scene_remove': { + if (msg.id) { + removePortal(msg.id); // handles both portals and regular objects + } + break; + } + + /** + * Clear all dynamic scene objects. + * { type: 'scene_clear' } + */ + case 'scene_clear': { + clearSceneObjects(); + logEvent('Scene cleared'); + break; + } + + /** + * Batch add — spawn multiple objects in one message. + * { type: 'scene_batch', objects: [...defs] } + */ + case 'scene_batch': { + if (Array.isArray(msg.objects)) { + let added = 0; + for (const objDef of msg.objects) { + if (objDef.geometry === 'portal') { + if (addPortal(objDef)) added++; + } else { + if (addSceneObject(objDef)) added++; + } + } + logEvent(`Batch: ${added} objects spawned`); + } + break; + } + + /* ═══════════════════════════════════════════════ + * Portals & Sub-worlds + * ═══════════════════════════════════════════════ */ + + /** + * Register a sub-world definition (blueprint). + * { type: 'world_register', id, label, objects: [...], ambient, spawn, returnPortal } + */ + case 'world_register': { + if (msg.id) { + registerWorld(msg); + logEvent(`World "${msg.label || msg.id}" registered`); + } + break; + } + + /** + * Load a sub-world by id. Clears current scene and spawns the world's objects. + * { type: 'world_load', id } + */ + case 'world_load': { + if (msg.id) { + if (msg.id === '__home') { + returnHome(); + logEvent('Returned to The Matrix'); + } else { + const spawn = loadWorld(msg.id); + if (spawn) { + logEvent(`Entered world: ${msg.id}`); + } + } + } + break; + } + + /** + * Unregister a world definition. + * { type: 'world_unregister', id } + */ + case 'world_unregister': { + if (msg.id) unregisterWorld(msg.id); + break; + } + + /* ═══════════════════════════════════════════════ + * Trigger Zones + * ═══════════════════════════════════════════════ */ + + /** + * Add a trigger zone. + * { type: 'zone_add', id, position, radius, action, payload, once } + */ + case 'zone_add': { + if (msg.id) addZone(msg); + break; + } + + /** + * Remove a trigger zone. + * { type: 'zone_remove', id } + */ + case 'zone_remove': { + if (msg.id) removeZone(msg.id); + break; + } + + /* ── Agent movement & behavior (Issues #67, #68) ── */ + + /** + * Backend-driven agent movement. + * { type: 'agent_move', agentId, target: {x, z}, speed? } + */ + case 'agent_move': { + if (msg.agentId && msg.target) { + const speed = msg.speed ?? 2.0; + moveAgentTo(msg.agentId, msg.target, speed); + } + break; + } + + /** + * Stop an agent's movement. + * { type: 'agent_stop', agentId } + */ + case 'agent_stop': { + if (msg.agentId) { + stopAgentMovement(msg.agentId); + } + break; + } + + /** + * Backend-driven behavior override. + * { type: 'agent_behavior', agentId, behavior, target?, duration? } + * Dispatched to the behavior system (behaviors.js) when loaded. + */ + case 'agent_behavior': { + // Forwarded to behavior system — dispatched via custom event + if (msg.agentId && msg.behavior) { + window.dispatchEvent(new CustomEvent('matrix:agent_behavior', { detail: msg })); + } + break; + } + + case 'pong': + case 'agent_count': + case 'ping': + break; + + default: + console.debug('[Matrix WS] Unhandled message type:', msg.type); + break; + } +} + +function logEvent(text) { + appendChatMessage('SYS', text, '#005500'); +} diff --git a/frontend/js/world.js b/frontend/js/world.js new file mode 100644 index 0000000..abfc724 --- /dev/null +++ b/frontend/js/world.js @@ -0,0 +1,95 @@ +import * as THREE from 'three'; +import { getMaxPixelRatio, getQualityTier } from './quality.js'; + +let scene, camera, renderer; +const _worldObjects = []; + +/** + * @param {HTMLCanvasElement|null} existingCanvas — pass the saved canvas on + * re-init so Three.js reuses the same DOM element instead of creating a new one + */ +export function initWorld(existingCanvas) { + scene = new THREE.Scene(); + scene.background = new THREE.Color(0x000000); + scene.fog = new THREE.FogExp2(0x000000, 0.035); + + camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 500); + camera.position.set(0, 12, 28); + camera.lookAt(0, 0, 0); + + const tier = getQualityTier(); + renderer = new THREE.WebGLRenderer({ + antialias: tier !== 'low', + canvas: existingCanvas || undefined, + }); + renderer.setSize(window.innerWidth, window.innerHeight); + renderer.setPixelRatio(Math.min(window.devicePixelRatio, getMaxPixelRatio())); + renderer.outputColorSpace = THREE.SRGBColorSpace; + + if (!existingCanvas) { + document.body.prepend(renderer.domElement); + } + + addLights(scene); + addGrid(scene, tier); + + return { scene, camera, renderer }; +} + +function addLights(scene) { + const ambient = new THREE.AmbientLight(0x001a00, 0.6); + scene.add(ambient); + + const point = new THREE.PointLight(0x00ff41, 2, 80); + point.position.set(0, 20, 0); + scene.add(point); + + const fill = new THREE.DirectionalLight(0x003300, 0.4); + fill.position.set(-10, 10, 10); + scene.add(fill); +} + +function addGrid(scene, tier) { + const gridDivisions = tier === 'low' ? 20 : 40; + const grid = new THREE.GridHelper(100, gridDivisions, 0x003300, 0x001a00); + grid.position.y = -0.01; + scene.add(grid); + _worldObjects.push(grid); + + const planeGeo = new THREE.PlaneGeometry(100, 100); + const planeMat = new THREE.MeshBasicMaterial({ + color: 0x000a00, + transparent: true, + opacity: 0.5, + }); + const plane = new THREE.Mesh(planeGeo, planeMat); + plane.rotation.x = -Math.PI / 2; + plane.position.y = -0.02; + scene.add(plane); + _worldObjects.push(plane); +} + +/** + * Dispose only world-owned geometries, materials, and the renderer. + * Agent and effect objects are disposed by their own modules before this runs. + */ +export function disposeWorld(disposeRenderer, _scene) { + for (const obj of _worldObjects) { + if (obj.geometry) obj.geometry.dispose(); + if (obj.material) { + const mats = Array.isArray(obj.material) ? obj.material : [obj.material]; + mats.forEach(m => { + if (m.map) m.map.dispose(); + m.dispose(); + }); + } + } + _worldObjects.length = 0; + disposeRenderer.dispose(); +} + +export function onWindowResize(camera, renderer) { + camera.aspect = window.innerWidth / window.innerHeight; + camera.updateProjectionMatrix(); + renderer.setSize(window.innerWidth, window.innerHeight); +} diff --git a/frontend/js/zones.js b/frontend/js/zones.js new file mode 100644 index 0000000..f8ad829 --- /dev/null +++ b/frontend/js/zones.js @@ -0,0 +1,161 @@ +/** + * zones.js — Proximity-based trigger zones for The Matrix. + * + * Zones are invisible volumes in the world that fire callbacks when + * the visitor avatar enters or exits them. Primary use case: portal + * traversal — walk into a portal zone → load a sub-world. + * + * Also used for: ambient music triggers, NPC interaction radius, + * info panels, and any spatial event the backend wants to define. + */ + +import * as THREE from 'three'; +import { sendMessage } from './websocket.js'; + +const zones = new Map(); // id → { center, radius, active, callbacks, meta } +let _visitorPos = new THREE.Vector3(0, 0, 22); // default spawn + +/** + * Register a trigger zone. + * + * @param {object} def + * @param {string} def.id — unique zone identifier + * @param {object} def.position — { x, y, z } center of the zone + * @param {number} def.radius — trigger radius (default 2) + * @param {string} def.action — what happens on enter: 'portal', 'notify', 'event' + * @param {object} def.payload — action-specific data (e.g. target world for portals) + * @param {boolean} def.once — if true, zone fires only once then deactivates + */ +export function addZone(def) { + if (!def.id) return false; + + zones.set(def.id, { + center: new THREE.Vector3( + def.position?.x ?? 0, + def.position?.y ?? 0, + def.position?.z ?? 0, + ), + radius: def.radius ?? 2, + action: def.action ?? 'notify', + payload: def.payload ?? {}, + once: def.once ?? false, + active: true, + _wasInside: false, + }); + + return true; +} + +/** + * Remove a zone by id. + */ +export function removeZone(id) { + return zones.delete(id); +} + +/** + * Clear all zones. + */ +export function clearZones() { + zones.clear(); +} + +/** + * Update visitor position (called from avatar/visitor movement code). + * @param {THREE.Vector3} pos + */ +export function setVisitorPosition(pos) { + _visitorPos.copy(pos); +} + +/** + * Per-frame check — test visitor against all active zones. + * Call from the render loop. + * + * @param {function} onPortalEnter — callback(zoneId, payload) for portal zones + */ +export function updateZones(onPortalEnter) { + for (const [id, zone] of zones) { + if (!zone.active) continue; + + const dist = _visitorPos.distanceTo(zone.center); + const isInside = dist <= zone.radius; + + if (isInside && !zone._wasInside) { + // Entered zone + _onEnter(id, zone, onPortalEnter); + } else if (!isInside && zone._wasInside) { + // Exited zone + _onExit(id, zone); + } + + zone._wasInside = isInside; + } +} + +/** + * Get all active zone definitions (for debugging / HUD display). + */ +export function getZoneSnapshot() { + const snap = {}; + for (const [id, z] of zones) { + snap[id] = { + position: { x: z.center.x, y: z.center.y, z: z.center.z }, + radius: z.radius, + action: z.action, + active: z.active, + }; + } + return snap; +} + +/* ── Internal handlers ── */ + +function _onEnter(id, zone, onPortalEnter) { + console.info('[Zones] Entered zone:', id, zone.action); + + switch (zone.action) { + case 'portal': + // Notify backend that visitor stepped into a portal + sendMessage({ + type: 'zone_entered', + zone_id: id, + action: 'portal', + payload: zone.payload, + }); + // Trigger portal transition in the renderer + if (onPortalEnter) onPortalEnter(id, zone.payload); + break; + + case 'event': + // Fire a custom event back to the backend + sendMessage({ + type: 'zone_entered', + zone_id: id, + action: 'event', + payload: zone.payload, + }); + break; + + case 'notify': + default: + // Just notify — backend can respond with barks, UI changes, etc. + sendMessage({ + type: 'zone_entered', + zone_id: id, + action: 'notify', + }); + break; + } + + if (zone.once) { + zone.active = false; + } +} + +function _onExit(id, zone) { + sendMessage({ + type: 'zone_exited', + zone_id: id, + }); +} diff --git a/frontend/style.css b/frontend/style.css new file mode 100644 index 0000000..f7cccd1 --- /dev/null +++ b/frontend/style.css @@ -0,0 +1,697 @@ +/* ===== THE MATRIX — SOVEREIGN AGENT WORLD ===== */ +/* Matrix Green/Noir Cyberpunk Aesthetic */ + +:root { + --matrix-green: #00ff41; + --matrix-green-dim: #008f11; + --matrix-green-dark: #003b00; + --matrix-cyan: #00d4ff; + --matrix-bg: #050505; + --matrix-surface: rgba(0, 255, 65, 0.04); + --matrix-surface-solid: #0a0f0a; + --matrix-border: rgba(0, 255, 65, 0.2); + --matrix-border-bright: rgba(0, 255, 65, 0.45); + --matrix-text: #b0ffb0; + --matrix-text-dim: #4a7a4a; + --matrix-text-bright: #00ff41; + --matrix-danger: #ff3333; + --matrix-warning: #ff8c00; + --matrix-purple: #9d4edd; + + --font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; + --panel-width: 360px; + --panel-blur: 20px; + --panel-radius: 4px; + --transition-panel: 350ms cubic-bezier(0.16, 1, 0.3, 1); + --transition-ui: 180ms cubic-bezier(0.16, 1, 0.3, 1); +} + +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, body { + width: 100%; + height: 100%; + overflow: hidden; + background: var(--matrix-bg); + font-family: var(--font-mono); + color: var(--matrix-text); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + touch-action: none; + user-select: none; + -webkit-user-select: none; +} + +canvas#matrix-canvas { + display: block; + width: 100%; + height: 100%; + position: fixed; + top: 0; + left: 0; +} + +/* ===== FPS Counter ===== */ +#fps-counter { + position: fixed; + top: 8px; + left: 8px; + z-index: 100; + font-family: var(--font-mono); + font-size: 11px; + line-height: 1.4; + color: var(--matrix-green-dim); + background: rgba(0, 0, 0, 0.5); + padding: 4px 8px; + border-radius: 2px; + pointer-events: none; + white-space: pre; + display: none; +} + +#fps-counter.visible { + display: block; +} + +/* ===== Panel Base ===== */ +.panel { + position: fixed; + top: 0; + right: 0; + width: var(--panel-width); + height: 100%; + z-index: 50; + display: flex; + flex-direction: column; + background: rgba(5, 10, 5, 0.88); + backdrop-filter: blur(var(--panel-blur)); + -webkit-backdrop-filter: blur(var(--panel-blur)); + border-left: 1px solid var(--matrix-border-bright); + transform: translateX(0); + transition: transform var(--transition-panel); + overflow: hidden; +} + +.panel.hidden { + transform: translateX(100%); + pointer-events: none; +} + +/* Scanline overlay on panel */ +.panel::before { + content: ''; + position: absolute; + inset: 0; + background: repeating-linear-gradient( + 0deg, + transparent, + transparent 2px, + rgba(0, 255, 65, 0.015) 2px, + rgba(0, 255, 65, 0.015) 4px + ); + pointer-events: none; + z-index: 1; +} + +.panel > * { + position: relative; + z-index: 2; +} + +/* ===== Panel Header ===== */ +.panel-header { + padding: 16px 16px 12px; + border-bottom: 1px solid var(--matrix-border); + flex-shrink: 0; +} + +.panel-agent-name { + font-size: 18px; + font-weight: 700; + color: var(--matrix-text-bright); + letter-spacing: 2px; + text-transform: uppercase; + text-shadow: 0 0 10px rgba(0, 255, 65, 0.5); +} + +.panel-agent-role { + font-size: 11px; + color: var(--matrix-text-dim); + margin-top: 2px; + letter-spacing: 1px; +} + +.panel-close { + position: absolute; + top: 12px; + right: 12px; + width: 28px; + height: 28px; + background: transparent; + border: 1px solid var(--matrix-border); + border-radius: 2px; + color: var(--matrix-text-dim); + font-size: 18px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all var(--transition-ui); + font-family: var(--font-mono); +} + +.panel-close:hover, .panel-close:active { + color: var(--matrix-text-bright); + border-color: var(--matrix-border-bright); + background: rgba(0, 255, 65, 0.08); +} + +/* ===== Tabs ===== */ +.panel-tabs { + display: flex; + border-bottom: 1px solid var(--matrix-border); + flex-shrink: 0; +} + +.tab { + flex: 1; + padding: 10px 8px; + background: transparent; + border: none; + border-bottom: 2px solid transparent; + color: var(--matrix-text-dim); + font-family: var(--font-mono); + font-size: 11px; + font-weight: 500; + letter-spacing: 1px; + text-transform: uppercase; + cursor: pointer; + transition: all var(--transition-ui); +} + +.tab:hover { + color: var(--matrix-text); + background: rgba(0, 255, 65, 0.04); +} + +.tab.active { + color: var(--matrix-text-bright); + border-bottom-color: var(--matrix-green); + text-shadow: 0 0 8px rgba(0, 255, 65, 0.4); +} + +/* ===== Panel Content ===== */ +.panel-content { + flex: 1; + overflow: hidden; + position: relative; +} + +.tab-content { + display: none; + flex-direction: column; + height: 100%; + overflow: hidden; +} + +.tab-content.active { + display: flex; +} + +/* ===== Chat ===== */ +.chat-messages { + flex: 1; + overflow-y: auto; + padding: 12px 16px; + -webkit-overflow-scrolling: touch; +} + +.chat-messages::-webkit-scrollbar { + width: 4px; +} + +.chat-messages::-webkit-scrollbar-track { + background: transparent; +} + +.chat-messages::-webkit-scrollbar-thumb { + background: var(--matrix-green-dark); + border-radius: 2px; +} + +.chat-msg { + margin-bottom: 12px; + padding: 8px 10px; + border-radius: 3px; + font-size: 12px; + line-height: 1.6; + word-break: break-word; +} + +.chat-msg.user { + background: rgba(0, 212, 255, 0.08); + border-left: 2px solid var(--matrix-cyan); + color: #b0eeff; +} + +.chat-msg.assistant { + background: rgba(0, 255, 65, 0.05); + border-left: 2px solid var(--matrix-green-dim); + color: var(--matrix-text); +} + +.chat-msg .msg-role { + font-size: 10px; + font-weight: 600; + letter-spacing: 1px; + text-transform: uppercase; + margin-bottom: 4px; + opacity: 0.6; +} + +.chat-input-area { + flex-shrink: 0; + padding: 8px 12px 12px; + border-top: 1px solid var(--matrix-border); +} + +.chat-input-row { + display: flex; + gap: 6px; +} + +#chat-input { + flex: 1; + background: rgba(0, 255, 65, 0.04); + border: 1px solid var(--matrix-border); + border-radius: 3px; + padding: 10px 12px; + color: var(--matrix-text-bright); + font-family: var(--font-mono); + font-size: 12px; + outline: none; + transition: border-color var(--transition-ui); +} + +#chat-input:focus { + border-color: var(--matrix-green); + box-shadow: 0 0 8px rgba(0, 255, 65, 0.15); +} + +#chat-input::placeholder { + color: var(--matrix-text-dim); +} + +.btn-send { + width: 40px; + background: rgba(0, 255, 65, 0.1); + border: 1px solid var(--matrix-border); + border-radius: 3px; + color: var(--matrix-green); + font-size: 14px; + cursor: pointer; + transition: all var(--transition-ui); + font-family: var(--font-mono); +} + +.btn-send:hover, .btn-send:active { + background: rgba(0, 255, 65, 0.2); + border-color: var(--matrix-green); +} + +/* Typing indicator */ +.typing-indicator { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 0 8px; + height: 24px; +} + +.typing-indicator.hidden { + display: none; +} + +.typing-indicator span { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--matrix-green-dim); + animation: typingDot 1.4s infinite both; +} + +.typing-indicator span:nth-child(2) { animation-delay: 0.2s; } +.typing-indicator span:nth-child(3) { animation-delay: 0.4s; } + +@keyframes typingDot { + 0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); } + 40% { opacity: 1; transform: scale(1.2); } +} + +/* ===== Status Tab ===== */ +.status-grid { + padding: 16px; + overflow-y: auto; + -webkit-overflow-scrolling: touch; +} + +.status-row { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 16px; + padding: 8px 0; + border-bottom: 1px solid rgba(0, 255, 65, 0.06); + font-size: 12px; +} + +.status-key { + color: var(--matrix-text-dim); + text-transform: uppercase; + letter-spacing: 1px; + font-size: 10px; + font-weight: 600; + white-space: nowrap; + flex-shrink: 0; +} + +.status-value { + color: var(--matrix-text-bright); + font-weight: 500; + text-align: right; + word-break: break-word; +} + +.status-value.state-working { + color: var(--matrix-green); + text-shadow: 0 0 6px rgba(0, 255, 65, 0.4); +} + +.status-value.state-idle { + color: var(--matrix-text-dim); +} + +.status-value.state-waiting { + color: var(--matrix-warning); +} + +/* ===== Tasks Tab ===== */ +.tasks-list { + padding: 12px 16px; + overflow-y: auto; + flex: 1; + -webkit-overflow-scrolling: touch; +} + +.task-item { + padding: 10px 12px; + margin-bottom: 8px; + background: rgba(0, 255, 65, 0.03); + border: 1px solid var(--matrix-border); + border-radius: 3px; +} + +.task-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; +} + +.task-status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.task-status-dot.pending { background: #ffffff; } +.task-status-dot.in_progress, .task-status-dot.in-progress { background: var(--matrix-warning); box-shadow: 0 0 6px rgba(255, 140, 0, 0.5); } +.task-status-dot.completed { background: var(--matrix-green); box-shadow: 0 0 6px rgba(0, 255, 65, 0.5); } +.task-status-dot.failed { background: var(--matrix-danger); box-shadow: 0 0 6px rgba(255, 51, 51, 0.5); } + +.task-title { + font-size: 12px; + font-weight: 500; + color: var(--matrix-text); + flex: 1; +} + +.task-priority { + font-size: 9px; + font-weight: 600; + letter-spacing: 1px; + text-transform: uppercase; + padding: 2px 6px; + border-radius: 2px; + background: rgba(0, 255, 65, 0.08); + color: var(--matrix-text-dim); +} + +.task-priority.high { + background: rgba(255, 51, 51, 0.15); + color: var(--matrix-danger); +} + +.task-priority.normal { + background: rgba(0, 255, 65, 0.08); + color: var(--matrix-text-dim); +} + +.task-actions { + display: flex; + gap: 6px; + margin-top: 8px; +} + +.task-btn { + flex: 1; + padding: 6px 8px; + font-family: var(--font-mono); + font-size: 10px; + font-weight: 600; + letter-spacing: 1px; + text-transform: uppercase; + border: 1px solid; + border-radius: 2px; + cursor: pointer; + transition: all var(--transition-ui); + background: transparent; +} + +.task-btn.approve { + border-color: rgba(0, 255, 65, 0.3); + color: var(--matrix-green); +} + +.task-btn.approve:hover { + background: rgba(0, 255, 65, 0.15); + border-color: var(--matrix-green); +} + +.task-btn.veto { + border-color: rgba(255, 51, 51, 0.3); + color: var(--matrix-danger); +} + +.task-btn.veto:hover { + background: rgba(255, 51, 51, 0.15); + border-color: var(--matrix-danger); +} + +/* ===== Memory Tab ===== */ +.memory-list { + padding: 12px 16px; + overflow-y: auto; + flex: 1; + -webkit-overflow-scrolling: touch; +} + +.memory-entry { + padding: 8px 10px; + margin-bottom: 6px; + border-left: 2px solid var(--matrix-green-dark); + font-size: 11px; + line-height: 1.5; + color: var(--matrix-text); +} + +.memory-timestamp { + font-size: 9px; + color: var(--matrix-text-dim); + letter-spacing: 1px; + margin-bottom: 2px; +} + +.memory-content { + color: var(--matrix-text); + opacity: 0.85; +} + +/* ===== Attribution ===== */ +.attribution { + position: fixed; + bottom: 6px; + left: 50%; + transform: translateX(-50%); + z-index: 10; + pointer-events: auto; +} + +.attribution a { + font-family: var(--font-mono); + font-size: 10px; + color: var(--matrix-green-dim); + text-decoration: none; + letter-spacing: 1px; + opacity: 0.7; + transition: opacity var(--transition-ui); + text-shadow: 0 0 4px rgba(0, 143, 17, 0.3); +} + +.attribution a:hover { + opacity: 1; + color: var(--matrix-green-dim); +} + +/* ===== Mobile / iPad ===== */ +@media (max-width: 768px) { + .panel { + width: 100%; + height: 60%; + top: auto; + bottom: 0; + right: 0; + border-left: none; + border-top: 1px solid var(--matrix-border-bright); + border-radius: 12px 12px 0 0; + } + + .panel.hidden { + transform: translateY(100%); + } + + .panel-agent-name { + font-size: 15px; + } + + .panel-tabs .tab { + font-size: 10px; + padding: 8px 4px; + } +} + +@media (max-width: 480px) { + .panel { + height: 70%; + } +} + +/* ── Help overlay ── */ + +#help-hint { + position: fixed; + top: 12px; + right: 12px; + font-family: 'Courier New', monospace; + font-size: 0.65rem; + color: #005500; + background: rgba(0, 10, 0, 0.6); + border: 1px solid #003300; + padding: 2px 8px; + cursor: pointer; + z-index: 30; + letter-spacing: 0.05em; + transition: color 0.3s, border-color 0.3s; +} +#help-hint:hover { + color: #00ff41; + border-color: #00ff41; +} + +#help-overlay { + position: fixed; + inset: 0; + z-index: 100; + background: rgba(0, 0, 0, 0.88); + display: flex; + align-items: center; + justify-content: center; + font-family: 'Courier New', monospace; + color: #00ff41; + backdrop-filter: blur(4px); +} + +.help-content { + position: relative; + max-width: 420px; + width: 90%; + padding: 24px 28px; + border: 1px solid #003300; + background: rgba(0, 10, 0, 0.7); +} + +.help-title { + font-size: 1rem; + letter-spacing: 0.15em; + margin-bottom: 20px; + color: #00ff41; + text-shadow: 0 0 8px rgba(0, 255, 65, 0.3); +} + +.help-close { + position: absolute; + top: 12px; + right: 16px; + font-size: 1.2rem; + cursor: pointer; + color: #005500; + transition: color 0.2s; +} +.help-close:hover { + color: #00ff41; +} + +.help-section { + margin-bottom: 16px; +} + +.help-heading { + font-size: 0.65rem; + color: #007700; + letter-spacing: 0.1em; + margin-bottom: 6px; + border-bottom: 1px solid #002200; + padding-bottom: 3px; +} + +.help-row { + display: flex; + align-items: center; + gap: 8px; + padding: 3px 0; + font-size: 0.72rem; +} + +.help-row span:last-child { + margin-left: auto; + color: #009900; + text-align: right; +} + +.help-row kbd { + display: inline-block; + font-family: 'Courier New', monospace; + font-size: 0.65rem; + background: rgba(0, 30, 0, 0.6); + border: 1px solid #004400; + border-radius: 3px; + padding: 1px 5px; + min-width: 18px; + text-align: center; + color: #00cc33; +} -- 2.43.0 From 2aac7df0869450d207cad42c046f8d3cffd8f186 Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Thu, 9 Apr 2026 02:25:31 -0400 Subject: [PATCH 2/2] feat: implement holographic memory bridge for Mnemosyne visuals --- nexus/nexus_think.py | 46 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/nexus/nexus_think.py b/nexus/nexus_think.py index 14d1996..676ee93 100644 --- a/nexus/nexus_think.py +++ b/nexus/nexus_think.py @@ -46,6 +46,7 @@ from nexus.perception_adapter import ( from nexus.experience_store import ExperienceStore from nexus.groq_worker import GroqWorker from nexus.trajectory_logger import TrajectoryLogger +import math, random logging.basicConfig( level=logging.INFO, @@ -326,6 +327,47 @@ class NexusMind: # ═══ WEBSOCKET ═══ + + async def _broadcast_memory_landscape(self): + """Broadcast current memory state as Memory Orbs to the frontend.""" + if not self.ws: + return + + # Get 15 most recent experiences + memories = self.experience_store.recent(limit=15) + if not memories: + return + + log.info(f"Broadcasting {len(memories)} memory orbs to Nexus frontend...") + + # Distribute orbs on a Fibonacci sphere for aesthetic layout + phi = math.pi * (3. - math.sqrt(5.)) # golden angle in radians + radius = 8.0 + + for i, exp in enumerate(memories): + # Fibonacci sphere coordinates + y = 1 - (i / float(len(memories) - 1)) * 2 if len(memories) > 1 else 0 + r = math.sqrt(1 - y * y) + theta = phi * i + + x = math.cos(theta) * r + z = math.sin(theta) * r + + # Format as a 'FACT_CREATED' event for the frontend Memory Bridge + # Using the experience ID as the fact_id + msg = { + "event": "FACT_CREATED", + "data": { + "fact_id": f"exp_{exp['id']}", + "category": "general", + "content": exp['perception'][:200], + "trust_score": 0.7 + (0.3 * (1.0 / (i + 1))), # Fade trust for older memories + "position": {"x": x * radius, "y": y * radius, "z": z * radius} + } + } + await self._ws_send(msg) + + async def _ws_send(self, msg: dict): """Send a message to the WS gateway.""" if self.ws: @@ -386,6 +428,7 @@ class NexusMind: while self.running: try: await self.think_once() + await self._broadcast_memory_landscape() except Exception as e: log.error(f"Think cycle error: {e}", exc_info=True) @@ -413,6 +456,9 @@ class NexusMind: log.info("=" * 50) # Run WS listener and think loop concurrently + # Initial memory landscape broadcast + await self._broadcast_memory_landscape() + await asyncio.gather( self._ws_listen(), self._think_loop(), -- 2.43.0