forked from Rockachopa/Timmy-time-dashboard
Co-authored-by: Claude (Opus 4.6) <claude@hermes.local> Co-committed-by: Claude (Opus 4.6) <claude@hermes.local>
170 lines
5.1 KiB
JavaScript
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;
|
|
}
|
|
}
|