From 1973aa1547bd41d124baabd807555bdbb58370f1 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 11 Apr 2026 14:19:41 +0200 Subject: [PATCH] feat: relay cross-machine vital/debuff sharing for MosswartMassacre Accepts new share_subscribe / share_unsubscribe / share_* WebSocket messages from MM plugin clients and fans them out to other opted-in plugin clients (excluding origin) and to browser clients for the NetworkUI window. - main.py: _vital_sharing_subscribers set, _vital_sharing_peer_state snapshot, _broadcast_share_to_plugin_clients relay, disconnect cleanup, GET /vital-sharing/peers endpoint. - static/index.html: new sidebar link for Vital Sharing window. - static/script.js: showVitalSharingWindow with live HP/STA/MANA bars, per-peer status dot/tags/position, 5s /vital-sharing/peers poll, and share_* routing through the existing browser WebSocket handler. Co-Authored-By: Claude Opus 4.6 (1M context) --- main.py | 134 ++++++++++++++++++++++++++++++++++++++++ static/index.html | 7 +++ static/script.js | 151 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 292 insertions(+) diff --git a/main.py b/main.py index 06e2e02a..bd428c6c 100644 --- a/main.py +++ b/main.py @@ -1714,6 +1714,25 @@ async def delete_issue(issue_id: str): return {"status": "ok"} +@app.get("/vital-sharing/peers") +async def get_vital_sharing_peers(): + """Return the current vital-sharing peer list for the NetworkUI window.""" + peers = [] + for char, entry in _vital_sharing_peer_state.items(): + peers.append( + { + **entry, + "subscribed": char in _vital_sharing_subscribers, + "plugin_connected": char in plugin_conns, + } + ) + peers.sort(key=lambda p: p.get("character_name") or "") + return { + "peers": peers, + "subscriber_count": len(_vital_sharing_subscribers), + } + + @app.get("/server-health") async def get_server_health(): """Return current server health status.""" @@ -2390,6 +2409,73 @@ browser_conns: set[WebSocket] = set() # Mapping of plugin clients by character_name to their WebSocket for command forwarding plugin_conns: Dict[str, WebSocket] = {} +# --- Vital sharing (cross-machine VTankFellowHeals replacement) ---------- +# Characters that have opted-in to vital/position/item/cast sharing. Backend +# forwards share_* messages only between subscribers and excludes the sender. +_vital_sharing_subscribers: set[str] = set() +# Latest snapshot per character so new peers can query /vital-sharing/peers +# and so the browser NetworkUI can populate without waiting for the next tick. +_vital_sharing_peer_state: Dict[str, dict] = {} + + +async def _broadcast_share_to_plugin_clients(data: dict, origin: str) -> None: + """Forward a share_* message to all opted-in plugin clients except origin.""" + if not _vital_sharing_subscribers: + return + stale: list[str] = [] + for char_name, ws in list(plugin_conns.items()): + if char_name == origin: + continue + if char_name not in _vital_sharing_subscribers: + continue + try: + await asyncio.wait_for(ws.send_json(data), timeout=1.0) + except Exception as e: + logger.debug(f"Failed forwarding share_* to {char_name}: {e}") + stale.append(char_name) + for name in stale: + _vital_sharing_subscribers.discard(name) + + +def _update_vital_sharing_peer_state(msg_type: str, data: dict) -> None: + """Keep the last-known vitals/position/item/tags snapshot per character.""" + char = data.get("character_name") + if not char: + return + entry = _vital_sharing_peer_state.setdefault( + char, + { + "character_name": char, + "tags": [], + "vitals": None, + "position": None, + "items": None, + "connected": True, + "last_update": None, + }, + ) + entry["last_update"] = data.get("timestamp") + if "tags" in data and isinstance(data.get("tags"), list): + entry["tags"] = data["tags"] + if msg_type == "share_vital_update": + entry["vitals"] = { + "current_health": data.get("current_health"), + "max_health": data.get("max_health"), + "current_stamina": data.get("current_stamina"), + "max_stamina": data.get("max_stamina"), + "current_mana": data.get("current_mana"), + "max_mana": data.get("max_mana"), + } + elif msg_type == "share_position_update": + entry["position"] = { + "ew": data.get("ew"), + "ns": data.get("ns"), + "z": data.get("z"), + "heading": data.get("heading"), + } + elif msg_type == "share_item_update": + entry["items"] = data.get("items") + async def _send_to_browser(ws: WebSocket, data: dict) -> WebSocket | None: """Send data to a single browser client. Returns the ws if it failed, None if ok.""" @@ -3084,6 +3170,51 @@ async def ws_receive_snapshots( ) await _broadcast_to_browser_clients(data) continue + + # ── Vital sharing (cross-machine VTankFellowHeals replacement) ── + if msg_type == "share_subscribe": + char = data.get("character_name") + if char: + _vital_sharing_subscribers.add(char) + tags = data.get("tags") or [] + entry = _vital_sharing_peer_state.setdefault( + char, + { + "character_name": char, + "tags": [], + "vitals": None, + "position": None, + "items": None, + "connected": True, + "last_update": None, + }, + ) + if isinstance(tags, list): + entry["tags"] = tags + entry["connected"] = True + logger.info( + f"🤝 VITAL_SHARING_SUBSCRIBED: {char} (tags={tags})" + ) + continue + + if msg_type == "share_unsubscribe": + char = data.get("character_name") + if char: + _vital_sharing_subscribers.discard(char) + if char in _vital_sharing_peer_state: + _vital_sharing_peer_state[char]["connected"] = False + logger.info(f"🤝 VITAL_SHARING_UNSUBSCRIBED: {char}") + continue + + if msg_type and msg_type.startswith("share_"): + origin = data.get("character_name") or "" + _update_vital_sharing_peer_state(msg_type, data) + # Fan out to other opted-in plugin clients + await _broadcast_share_to_plugin_clients(data, origin) + # Fan out to browser clients for NetworkUI display + await _broadcast_to_browser_clients(data) + continue + # Unknown message types are ignored if msg_type: logger.warning( @@ -3099,6 +3230,9 @@ async def ws_receive_snapshots( plugin_conns.pop(name, None) live_equipment_cantrip_states.pop(name, None) live_nearby_objects.pop(name, None) + _vital_sharing_subscribers.discard(name) + if name in _vital_sharing_peer_state: + _vital_sharing_peer_state[name]["connected"] = False # Clean up any plugin registrations for this socket to_remove = [n for n, ws in plugin_conns.items() if ws is websocket] diff --git a/static/index.html b/static/index.html index 365e2776..c6df10a5 100644 --- a/static/index.html +++ b/static/index.html @@ -104,6 +104,13 @@ 📋 Issues Board + + +
diff --git a/static/script.js b/static/script.js index 00d2a500..2b4ef9b7 100644 --- a/static/script.js +++ b/static/script.js @@ -3074,6 +3074,8 @@ function initWebSocket() { const rw = radarWindows[msg.character_name]; if (rw) rw._radarDungeonLandblock = msg.landblock; } + } else if (typeof msg.type === 'string' && msg.type.startsWith('share_')) { + updateVitalSharingPeer(msg); } }); socket.addEventListener('close', () => setTimeout(initWebSocket, 2000)); @@ -4429,3 +4431,152 @@ function showCommentsSection(row, issue, win) { section.appendChild(addDiv); row.appendChild(section); } + +// ─── Vital Sharing NetworkUI ─────────────────────────────────────────────── +// Live state fed by share_* WebSocket messages AND by /vital-sharing/peers +// polling. Keyed by character_name. +const vitalSharingPeers = {}; +let _vitalSharingPollTimer = null; + +function updateVitalSharingPeer(msg) { + const name = msg.character_name; + if (!name) return; + const entry = vitalSharingPeers[name] || (vitalSharingPeers[name] = { + character_name: name, + tags: [], + vitals: null, + position: null, + last_update: null, + connected: true, + }); + entry.last_update = msg.timestamp || new Date().toISOString(); + if (Array.isArray(msg.tags)) entry.tags = msg.tags; + if (msg.type === 'share_vital_update') { + entry.vitals = { + current_health: msg.current_health, + max_health: msg.max_health, + current_stamina: msg.current_stamina, + max_stamina: msg.max_stamina, + current_mana: msg.current_mana, + max_mana: msg.max_mana, + }; + } else if (msg.type === 'share_position_update') { + entry.position = { + ew: msg.ew, ns: msg.ns, z: msg.z, heading: msg.heading, + }; + } + renderVitalSharingWindow(); +} + +function showVitalSharingWindow() { + const { win, content, isNew } = createWindow( + 'vitalSharingWindow', 'Vital Sharing Network', 'issues-window', + { onClose: () => { + if (_vitalSharingPollTimer) { + clearInterval(_vitalSharingPollTimer); + _vitalSharingPollTimer = null; + } + } + } + ); + + if (!isNew) { + refreshVitalSharingPeers(); + return; + } + + win.style.width = '520px'; + win.style.height = '500px'; + + const listDiv = document.createElement('div'); + listDiv.className = 'vital-sharing-list'; + listDiv.style.cssText = 'padding:6px;overflow-y:auto;flex:1;'; + content.appendChild(listDiv); + win._vitalSharingList = listDiv; + + refreshVitalSharingPeers(); + if (_vitalSharingPollTimer) clearInterval(_vitalSharingPollTimer); + _vitalSharingPollTimer = setInterval(refreshVitalSharingPeers, 5000); +} + +async function refreshVitalSharingPeers() { + try { + const resp = await fetch('/vital-sharing/peers'); + if (!resp.ok) return; + const data = await resp.json(); + (data.peers || []).forEach(p => { + const existing = vitalSharingPeers[p.character_name] || {}; + vitalSharingPeers[p.character_name] = { ...existing, ...p }; + }); + } catch (e) { + // ignore transient errors + } + renderVitalSharingWindow(); +} + +function renderVitalSharingWindow() { + const win = document.getElementById('vitalSharingWindow'); + if (!win || win.style.display === 'none') return; + const listDiv = win._vitalSharingList; + if (!listDiv) return; + + const peers = Object.values(vitalSharingPeers).sort((a, b) => + (a.character_name || '').localeCompare(b.character_name || '') + ); + + if (peers.length === 0) { + listDiv.innerHTML = '
No vital-sharing peers connected
'; + return; + } + + listDiv.innerHTML = ''; + peers.forEach(p => { + const row = document.createElement('div'); + row.className = 'vital-sharing-peer'; + row.style.cssText = 'border:1px solid #444;background:#1f1f1f;padding:6px 8px;margin-bottom:5px;border-radius:3px;font-size:0.78rem;color:#ddd;'; + + const dot = p.plugin_connected + ? '' + : ''; + const subscribed = p.subscribed ? ' [subscribed]' : ''; + const tags = (p.tags || []).join(', ') || 'no tags'; + + let vitalsHtml = '(no vitals yet)'; + if (p.vitals && p.vitals.max_health) { + const v = p.vitals; + const pct = (cur, max) => max > 0 ? Math.max(0, Math.min(100, (cur / max) * 100)) : 0; + const bar = (label, cur, max, color) => ` +
+ ${label} +
+
+
+ ${cur ?? 0}/${max ?? 0} +
`; + vitalsHtml = + bar('HP', v.current_health, v.max_health, '#c44') + + bar('STA', v.current_stamina, v.max_stamina, '#4c4') + + bar('MANA', v.current_mana, v.max_mana, '#46c'); + } + + let posHtml = ''; + if (p.position && p.position.ew !== undefined) { + const fmt = (n) => (typeof n === 'number' ? n.toFixed(1) : n); + posHtml = `
+ ${fmt(p.position.ns)}N, ${fmt(p.position.ew)}E z=${fmt(p.position.z)} +
`; + } + + row.innerHTML = ` +
+ ${dot} + ${p.character_name} + ${subscribed} +
+
tags: ${tags}
+ ${vitalsHtml} + ${posHtml} + `; + listDiv.appendChild(row); + }); +}