/** * State reader — hardcoded JSON for Phase 2, WebSocket in Phase 3. * * Provides Timmy's current state to the scene. In Phase 2 this is a * static default; the WebSocket path is stubbed for future use. * * Also manages connection health monitoring: pings /api/matrix/health * every 30 seconds and notifies listeners when online/offline state * changes so the Workshop can replay any queued messages. */ const DEFAULTS = { timmyState: { mood: "focused", activity: "Pondering the arcane arts", energy: 0.6, confidence: 0.7, }, activeThreads: [], recentEvents: [], concerns: [], visitorPresent: false, updatedAt: new Date().toISOString(), version: 1, }; const _HEALTH_URL = "/api/matrix/health"; const _PING_INTERVAL_MS = 30_000; const _WS_RECONNECT_DELAY_MS = 5_000; export class StateReader { constructor() { this.state = { ...DEFAULTS }; this.listeners = []; this.connectionListeners = []; this._ws = null; this._online = false; this._pingTimer = null; this._reconnectTimer = null; } /** Subscribe to state changes. */ onChange(fn) { this.listeners.push(fn); } /** Subscribe to online/offline transitions. Called with (isOnline: bool). */ onConnectionChange(fn) { this.connectionListeners.push(fn); } /** Notify all state listeners. */ _notify() { for (const fn of this.listeners) { try { fn(this.state); } catch (e) { console.warn("State listener error:", e); } } } /** Fire connection listeners only when state actually changes. */ _notifyConnection(online) { if (online === this._online) return; this._online = online; for (const fn of this.connectionListeners) { try { fn(online); } catch (e) { console.warn("Connection listener error:", e); } } } /** Ping the health endpoint once and update connection state. */ async _ping() { try { const r = await fetch(_HEALTH_URL, { signal: AbortSignal.timeout(5000), }); this._notifyConnection(r.ok); } catch { this._notifyConnection(false); } } /** Start 30-second health-check loop (idempotent). */ _startHealthCheck() { if (this._pingTimer) return; this._pingTimer = setInterval(() => this._ping(), _PING_INTERVAL_MS); } /** Schedule a WebSocket reconnect attempt after a delay (idempotent). */ _scheduleReconnect() { if (this._reconnectTimer) return; this._reconnectTimer = setTimeout(() => { this._reconnectTimer = null; this._connectWS(); }, _WS_RECONNECT_DELAY_MS); } /** Open (or re-open) the WebSocket connection. */ _connectWS() { const proto = location.protocol === "https:" ? "wss:" : "ws:"; const url = `${proto}//${location.host}/api/world/ws`; try { this._ws = new WebSocket(url); this._ws.onopen = () => { const dot = document.getElementById("connection-dot"); if (dot) dot.classList.add("connected"); this._notifyConnection(true); }; this._ws.onclose = () => { const dot = document.getElementById("connection-dot"); if (dot) dot.classList.remove("connected"); this._notifyConnection(false); this._scheduleReconnect(); }; this._ws.onmessage = (ev) => { try { const msg = JSON.parse(ev.data); if (msg.type === "world_state" || msg.type === "timmy_state") { if (msg.timmyState) this.state.timmyState = msg.timmyState; if (msg.mood) { this.state.timmyState.mood = msg.mood; this.state.timmyState.activity = msg.activity || ""; this.state.timmyState.energy = msg.energy ?? 0.5; } this._notify(); } } catch (e) { /* ignore parse errors */ } }; } catch (e) { console.warn("WebSocket unavailable — using static state"); this._scheduleReconnect(); } } /** Connect to the world WebSocket and start health-check polling. */ connect() { this._connectWS(); this._startHealthCheck(); // Immediate ping so connection status is known before the first interval. this._ping(); } /** Current mood string. */ get mood() { return this.state.timmyState.mood; } /** Current activity string. */ get activity() { return this.state.timmyState.activity; } /** Energy level 0-1. */ get energy() { return this.state.timmyState.energy; } /** Whether the server is currently reachable. */ get isOnline() { return this._online; } }