Files
Timmy-time-dashboard/static/world/state.js
Claude (Opus 4.6) a5b95356dd
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
[claude] Add offline message queue for Workshop panel (#913) (#1205)
Co-authored-by: Claude (Opus 4.6) <claude@hermes.local>
Co-committed-by: Claude (Opus 4.6) <claude@hermes.local>
2026-03-23 22:16:27 +00:00

170 lines
5.1 KiB
JavaScript

/**
* 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;
}
}