import * as THREE from 'three'; import { scene } from './world.js'; // Import the scene import { setAgentState, setSpeechBubble, applyAgentStates, setMood, TIMMY_WORLD_POS } from './agents.js'; import { appendSystemMessage, appendDebateMessage, showCostTicker, updateCostTicker } from './ui.js'; import { sentiment } from './edge-worker-client.js'; import { setLabelState, setLabelLastTask } from './hud-labels.js'; import { createJobIndicator, dissolveJobIndicator } from './effects.js'; import { getPubkey } from './nostr-identity.js'; function resolveWsUrl() { const explicit = import.meta.env.VITE_WS_URL; if (explicit) return explicit; const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; return `${proto}//${window.location.host}/api/ws`; } const WS_URL = resolveWsUrl(); let ws = null; let connectionState = 'disconnected'; let jobCount = 0; let reconnectTimer = null; let visitorId = null; const RECONNECT_DELAY_MS = 5000; // Map to keep track of active job indicator positions for offsetting const _jobIndicatorOffsets = new Map(); let _nextJobOffsetIndex = 0; export function initWebSocket(_scene) { visitorId = crypto.randomUUID(); connect(); } function connect() { if (ws) { ws.onclose = null; ws.close(); } connectionState = 'connecting'; try { ws = new WebSocket(WS_URL); } catch { connectionState = 'disconnected'; scheduleReconnect(); return; } ws.onopen = () => { connectionState = 'connected'; clearTimeout(reconnectTimer); const npub = getPubkey(); send({ type: 'visitor_enter', visitorId, visitorName: 'visitor', npub }); }; ws.onmessage = event => { try { handleMessage(JSON.parse(event.data)); } catch { /* ignore */ } }; ws.onerror = () => { connectionState = 'disconnected'; }; ws.onclose = () => { connectionState = 'disconnected'; scheduleReconnect(); }; } function scheduleReconnect() { clearTimeout(reconnectTimer); reconnectTimer = setTimeout(connect, RECONNECT_DELAY_MS); } function send(payload) { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(payload)); } } function handleMessage(msg) { switch (msg.type) { case 'ping': send({ type: 'pong' }); break; case 'world_state': { if (msg.agentStates) applyAgentStates(msg.agentStates); if (msg.recentEvents) { const last = msg.recentEvents.slice(-3); last.forEach(ev => appendSystemMessage(ev.summary || ev.type)); } break; } case 'timmy_state': { break; } case 'agent_state': { if (msg.agentId && msg.state) { setAgentState(msg.agentId, msg.state); setLabelState(msg.agentId, msg.state); } break; } case 'job_started': { jobCount++; if (msg.agentId) { setAgentState(msg.agentId, 'active'); setLabelState(msg.agentId, 'active'); } appendSystemMessage(`job ${(msg.jobId || '').slice(0, 8)} started`); // Spawn 3D job indicator if (msg.jobId && msg.category) { const offsetMultiplier = _jobIndicatorOffsets.size; // Simple way to spread them out const indicatorPosition = TIMMY_WORLD_POS.clone().add( new THREE.Vector3( (offsetMultiplier % 2 === 0 ? 1 : -1) * (Math.floor(offsetMultiplier / 2) + 1) * 0.7, // Alternate left/right 3.5, // Height above Timmy -0.5 ) ); const indicator = createJobIndicator(msg.category, msg.jobId, indicatorPosition); scene.add(indicator); _jobIndicatorOffsets.set(msg.jobId, indicatorPosition); // Store position, not index, for cleaner removal } break; } case 'agent_task_summary': { if (msg.agentId && msg.summary) { setLabelLastTask(msg.agentId, msg.summary); } break; } case 'job_completed': { if (jobCount > 0) jobCount--; if (msg.agentId) { setAgentState(msg.agentId, 'idle'); setLabelState(msg.agentId, 'idle'); setLabelLastTask(msg.agentId, `job ${(msg.jobId || '').slice(0, 8)} completed`); } appendSystemMessage(`job ${(msg.jobId || '').slice(0, 8)} complete`); // Dissolve 3D job indicator if (msg.jobId) { dissolveJobIndicator(msg.jobId, scene); _jobIndicatorOffsets.delete(msg.jobId); } break; } case 'chat': { if (msg.agentId === 'timmy') { // Timmy's AI reply: show in speech bubble + event log if (msg.text) setSpeechBubble(msg.text); appendSystemMessage('Timmy: ' + (msg.text || '').slice(0, 80)); // Sentiment-driven facial expression on inbound Timmy messages if (msg.text) { sentiment(msg.text).then(s => { setMood(s.label); setTimeout(() => setMood(null), 10_000); }).catch(() => {}); } } else if (msg.agentId === 'visitor') { // Another visitor's message: event log only (don't hijack the speech bubble) appendSystemMessage((msg.text || '').slice(0, 80)); } else { // System agent messages (delta payment confirmations, etc.): speech bubble if (msg.text) setSpeechBubble(msg.text); } break; } case 'agent_debate': { // Debate messages from Beta-A, Beta-B, or final verdict (#21) const isVerdict = msg.position === 'verdict'; appendDebateMessage(msg.agent, msg.argument, isVerdict, msg.accepted); if (isVerdict) { setSpeechBubble(msg.argument); } break; } case 'cost_update': { // Real-time cost ticker (#68): show estimated cost when job starts, // update to final charged amount when job completes. if (msg.isFinal) { updateCostTicker(msg.sats, true); } else { showCostTicker(msg.sats); } break; } case 'agent_commentary': { // Agent narration during job lifecycle if (msg.text) { setSpeechBubble(msg.text); appendSystemMessage(`${msg.agentId}: ${(msg.text || '').slice(0, 80)}`); } break; } case 'agent_count': case 'visitor_count': break; default: break; } } export function sendVisitorMessage(text) { send({ type: 'visitor_message', visitorId, text }); } export function getConnectionState() { return connectionState; } export function getJobCount() { return jobCount; }