diff --git a/app.js b/app.js index 21c5c222..8140273b 100644 --- a/app.js +++ b/app.js @@ -5,6 +5,7 @@ import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js' import { SMAAPass } from 'three/addons/postprocessing/SMAAPass.js'; import { SpatialMemory } from './nexus/components/spatial-memory.js'; import { SessionRooms } from './nexus/components/session-rooms.js'; +import { EvenniaRoomPanel } from './nexus/components/evennia-room-panel.js'; // ═══════════════════════════════════════════ // NEXUS v1.1 — Portal System Update @@ -707,6 +708,7 @@ async function init() { createAshStorm(); SpatialMemory.init(scene); SessionRooms.init(scene, camera, null); + EvenniaRoomPanel.init(); updateLoad(90); loadSession(); @@ -2074,6 +2076,7 @@ function connectHermes() { addChatMessage('system', 'Hermes link established.'); updateWsHudStatus(true); refreshWorkshopPanel(); + EvenniaRoomPanel.setConnected(true); }; // Initialize MemPalace @@ -2102,6 +2105,7 @@ function connectHermes() { hermesWs = null; updateWsHudStatus(false); refreshWorkshopPanel(); + EvenniaRoomPanel.setConnected(false); if (wsReconnectTimer) clearTimeout(wsReconnectTimer); wsReconnectTimer = setTimeout(connectHermes, 5000); }; @@ -2112,6 +2116,16 @@ function connectHermes() { } function handleHermesMessage(data) { + // ── Evennia room snapshot events (#728) ── + if (data.type === 'evennia.room_snapshot') { + EvenniaRoomPanel.onRoomSnapshot(data); + return; + } + if (data.type === 'evennia.actor_located') { + EvenniaRoomPanel.onActorLocated(data); + return; + } + if (data.type === 'chat') { addChatMessage(data.agent || 'timmy', data.text); } else if (data.type === 'tool_call') { diff --git a/index.html b/index.html index 54ada489..7c812145 100644 --- a/index.html +++ b/index.html @@ -125,6 +125,19 @@
AGENT THOUGHT STREAM
+ +
+
+ + EVENNIA ROOM + +
+
+
+
DISCONNECTED
+
No link to Evennia world.
+
+
diff --git a/nexus/components/evennia-room-panel.js b/nexus/components/evennia-room-panel.js new file mode 100644 index 00000000..6dbe6176 --- /dev/null +++ b/nexus/components/evennia-room-panel.js @@ -0,0 +1,229 @@ +// ═══════════════════════════════════════════════════════ +// EVENNIA ROOM SNAPSHOT OPERATOR PANEL (Issue #728) +// ═══════════════════════════════════════════════════════ +// +// Renders the current Evennia room state in the Nexus HUD. +// Consumes evennia.room_snapshot and evennia.actor_located +// events from the Hermes WebSocket bridge. +// +// States: +// offline — no WS connection +// awaiting — connected but no room data yet +// in-room — room snapshot loaded, render full panel +// +// Usage from app.js: +// EvenniaRoomPanel.init(); +// EvenniaRoomPanel.onRoomSnapshot(data); +// EvenniaRoomPanel.onActorLocated(data); +// EvenniaRoomPanel.setConnected(bool); +// ═══════════════════════════════════════════════════════ + +export const EvenniaRoomPanel = (() => { + + // ─── STATE ──────────────────────────────────────────── + let _connected = false; + let _roomData = null; // latest evennia.room_snapshot payload + let _actorRoomId = null; // from evennia.actor_located + let _lastUpdate = null; // timestamp of last snapshot + let _panelEl = null; // DOM root + let _init = false; + + // ─── DOM REFS ───────────────────────────────────────── + function _el(id) { return document.getElementById(id); } + + // ─── INIT ───────────────────────────────────────────── + function init() { + _panelEl = _el('evennia-room-panel'); + if (!_panelEl) { + console.warn('[EvenniaRoomPanel] Panel element not found in DOM.'); + return; + } + _init = true; + _render(); + console.log('[EvenniaRoomPanel] Initialized.'); + } + + // ─── EVENT HANDLERS ─────────────────────────────────── + + function onRoomSnapshot(data) { + _roomData = data; + _lastUpdate = data.timestamp || new Date().toISOString(); + _actorRoomId = data.room_id || data.room_key || null; + _render(); + } + + function onActorLocated(data) { + _actorRoomId = data.room_id || data.room_key || null; + // If we get a location but no snapshot yet, show awaiting + if (!_roomData || (_roomData.room_id !== _actorRoomId && _roomData.room_key !== _actorRoomId)) { + _render(); + } + } + + function setConnected(connected) { + _connected = connected; + if (!connected) { + // Clear room data on disconnect — stale data is lying + _roomData = null; + _actorRoomId = null; + _lastUpdate = null; + } + _render(); + } + + // ─── RENDER ─────────────────────────────────────────── + + function _render() { + if (!_panelEl) return; + + if (!_connected) { + _renderOffline(); + } else if (!_roomData) { + _renderAwaiting(); + } else { + _renderRoom(); + } + } + + function _renderOffline() { + _panelEl.innerHTML = ` +
+ + EVENNIA ROOM + +
+
+
+
DISCONNECTED
+
No link to Evennia world.
+
+ `; + _panelEl.classList.add('erp-state-offline'); + _panelEl.classList.remove('erp-state-awaiting', 'erp-state-inroom'); + } + + function _renderAwaiting() { + _panelEl.innerHTML = ` +
+ + EVENNIA ROOM + +
+
+
+
AWAITING SNAPSHOT
+
Connected. Waiting for room data…
+
+ `; + _panelEl.classList.add('erp-state-awaiting'); + _panelEl.classList.remove('erp-state-offline', 'erp-state-inroom'); + } + + function _renderRoom() { + const room = _roomData; + const title = _esc(room.title || room.room_name || room.room_key || 'Unknown Room'); + const desc = _esc(room.desc || 'No description available.'); + const exits = Array.isArray(room.exits) ? room.exits : []; + const objects = Array.isArray(room.objects) ? room.objects : []; + const occupants = Array.isArray(room.occupants) ? room.occupants : []; + const roomId = _esc(room.room_id || room.room_key || '—'); + const timeStr = _formatTime(_lastUpdate); + + // Build exits list + let exitsHtml = ''; + if (exits.length > 0) { + exitsHtml = exits.map(e => { + const name = _esc(e.key || e.name || '?'); + const dest = _esc(e.destination_name || e.destination_id || e.destination_key || ''); + return `
+ + ${name} + ${dest ? `${dest}` : ''} +
`; + }).join(''); + } else { + exitsHtml = '
No visible exits.
'; + } + + // Build objects list + let objectsHtml = ''; + if (objects.length > 0) { + objectsHtml = objects.map(o => { + const name = _esc(o.key || o.id || '?'); + const desc = _esc(o.short_desc || ''); + return `
+ + ${name} + ${desc ? `${desc}` : ''} +
`; + }).join(''); + } else { + objectsHtml = '
No visible objects.
'; + } + + // Build occupants list + let occupantsHtml = ''; + if (occupants.length > 0) { + occupantsHtml = occupants.map(o => { + const name = _esc(typeof o === 'string' ? o : (o.name || o.key || '?')); + return `${name}`; + }).join(''); + } + + _panelEl.innerHTML = ` +
+ + ${title} + +
+
+
${roomId}
+
${desc}
+ +
+ +
${exitsHtml}
+
+ +
+ +
${objectsHtml}
+
+ + ${occupantsHtml ? ` +
+ +
${occupantsHtml}
+
` : ''} + + +
+ `; + _panelEl.classList.add('erp-state-inroom'); + _panelEl.classList.remove('erp-state-offline', 'erp-state-awaiting'); + } + + // ─── UTILS ──────────────────────────────────────────── + + function _esc(str) { + const d = document.createElement('div'); + d.textContent = str; + return d.innerHTML; + } + + function _formatTime(isoStr) { + if (!isoStr) return '—'; + try { + const d = new Date(isoStr); + return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }); + } catch { + return '—'; + } + } + + // ─── PUBLIC API ─────────────────────────────────────── + return { init, onRoomSnapshot, onActorLocated, setConnected }; + +})(); diff --git a/style.css b/style.css index 2c3f70eb..433d6f2d 100644 --- a/style.css +++ b/style.css @@ -1580,3 +1580,229 @@ canvas#nexus-canvas { text-transform: uppercase; } +/* ═══════════════════════════════════════════════════════ + EVENNIA ROOM SNAPSHOT OPERATOR PANEL (#728) + ═══════════════════════════════════════════════════════ */ + +.evennia-room-panel { + width: 280px; + background: rgba(5, 5, 16, 0.85); + backdrop-filter: blur(12px); + border: 1px solid rgba(74, 240, 192, 0.18); + border-left: 3px solid var(--color-primary, #4af0c0); + border-radius: 6px; + font-family: var(--font-body, 'JetBrains Mono', monospace); + font-size: 11px; + color: var(--color-text, #e0f0ff); + pointer-events: auto; + overflow: hidden; + transition: border-color 0.3s ease; +} + +.evennia-room-panel.erp-state-offline { + border-left-color: var(--color-danger, #ff4466); +} + +.evennia-room-panel.erp-state-awaiting { + border-left-color: var(--color-warning, #ffaa22); +} + +.evennia-room-panel.erp-state-inroom { + border-left-color: var(--color-primary, #4af0c0); +} + +.erp-header { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + border-bottom: 1px solid rgba(74, 240, 192, 0.1); + background: rgba(74, 240, 192, 0.04); +} + +.erp-icon { + font-size: 12px; + color: var(--color-primary, #4af0c0); +} + +.erp-title { + font-size: 10px; + font-weight: 700; + letter-spacing: 1.2px; + color: var(--color-primary, #4af0c0); + flex: 1; +} + +.erp-status-dot { + width: 7px; + height: 7px; + border-radius: 50%; + flex-shrink: 0; +} + +.erp-status-dot.erp-offline { + background: var(--color-danger, #ff4466); + box-shadow: 0 0 6px var(--color-danger, #ff4466); +} + +.erp-status-dot.erp-online { + background: var(--color-primary, #4af0c0); + box-shadow: 0 0 6px var(--color-primary, #4af0c0); +} + +.erp-body { + padding: 8px 10px; + max-height: 320px; + overflow-y: auto; +} + +/* Empty / offline states */ +.erp-empty-state { + text-align: center; + padding: 18px 10px; +} + +.erp-empty-icon { + font-size: 22px; + color: rgba(74, 240, 192, 0.25); + margin-bottom: 6px; +} + +.erp-empty-icon.erp-pulse { + animation: erpPulse 2s ease-in-out infinite; +} + +@keyframes erpPulse { + 0%, 100% { opacity: 0.35; } + 50% { opacity: 0.9; } +} + +.erp-empty-label { + font-size: 10px; + font-weight: 700; + letter-spacing: 1.5px; + color: var(--color-text-muted, #8a9ab8); + margin-bottom: 4px; +} + +.erp-empty-hint { + font-size: 10px; + color: rgba(138, 154, 184, 0.55); +} + +/* Room content */ +.erp-room-id { + font-size: 9px; + color: rgba(138, 154, 184, 0.4); + letter-spacing: 0.8px; + text-transform: uppercase; + margin-bottom: 6px; +} + +.erp-desc { + font-size: 11px; + color: rgba(224, 240, 255, 0.8); + line-height: 1.45; + margin-bottom: 10px; + border-left: 2px solid rgba(74, 240, 192, 0.15); + padding-left: 8px; +} + +.erp-section { + margin-bottom: 8px; +} + +.erp-section-label { + font-size: 9px; + font-weight: 700; + letter-spacing: 1.5px; + color: var(--color-primary, #4af0c0); + margin-bottom: 4px; + opacity: 0.7; +} + +.erp-none { + font-size: 10px; + color: rgba(138, 154, 184, 0.35); + font-style: italic; + padding: 2px 0; +} + +/* Exits */ +.erp-exit-row { + display: flex; + align-items: center; + gap: 6px; + padding: 2px 0; + font-size: 11px; +} + +.erp-exit-arrow { + color: var(--color-secondary, #7b5cff); + font-weight: 700; +} + +.erp-exit-name { + color: var(--color-secondary, #7b5cff); + font-weight: 600; +} + +.erp-exit-dest { + color: rgba(138, 154, 184, 0.45); + font-size: 10px; +} + +/* Objects */ +.erp-object-row { + display: flex; + align-items: baseline; + gap: 5px; + padding: 2px 0; + font-size: 11px; +} + +.erp-object-icon { + color: var(--color-gold, #ffd700); + font-size: 8px; +} + +.erp-object-name { + color: rgba(224, 240, 255, 0.75); + font-weight: 500; +} + +.erp-object-desc { + color: rgba(138, 154, 184, 0.45); + font-size: 10px; +} + +/* Occupants */ +.erp-occupants { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.erp-occupant { + font-size: 10px; + padding: 2px 6px; + background: rgba(74, 240, 192, 0.08); + border: 1px solid rgba(74, 240, 192, 0.15); + border-radius: 3px; + color: var(--color-primary, #4af0c0); +} + +/* Footer */ +.erp-footer { + margin-top: 6px; + padding-top: 4px; + border-top: 1px solid rgba(74, 240, 192, 0.06); + text-align: right; +} + +.erp-time { + font-size: 9px; + color: rgba(138, 154, 184, 0.3); + letter-spacing: 0.5px; +} +