690 lines
19 KiB
JavaScript
690 lines
19 KiB
JavaScript
/**
|
|
* 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');
|
|
}
|