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) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-11 14:19:41 +02:00
parent 52d57c9121
commit 1973aa1547
3 changed files with 292 additions and 0 deletions

View file

@ -104,6 +104,13 @@
📋 Issues Board
</a>
</div>
<!-- Vital Sharing Network link -->
<div class="quest-status-link">
<a href="#" id="vitalSharingBtn" onclick="showVitalSharingWindow()">
🤝 Vital Sharing
</a>
</div>
<!-- Container for sort and filter controls -->
<div id="sortButtons" class="sort-buttons"></div>

View file

@ -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 = '<div style="padding:10px;color:#888;text-align:center;font-size:0.8rem;">No vital-sharing peers connected</div>';
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
? '<span style="color:#4c4;">●</span>'
: '<span style="color:#a33;">●</span>';
const subscribed = p.subscribed ? ' <span style="color:#6bf;">[subscribed]</span>' : '';
const tags = (p.tags || []).join(', ') || '<span style="color:#666;">no tags</span>';
let vitalsHtml = '<span style="color:#666;">(no vitals yet)</span>';
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) => `
<div style="display:flex;align-items:center;gap:4px;margin:1px 0;">
<span style="width:30px;color:#888;font-size:0.7rem;">${label}</span>
<div style="flex:1;height:8px;background:#222;border:1px solid #333;border-radius:2px;overflow:hidden;">
<div style="width:${pct(cur, max).toFixed(0)}%;height:100%;background:${color};"></div>
</div>
<span style="width:70px;text-align:right;font-size:0.7rem;color:#aaa;">${cur ?? 0}/${max ?? 0}</span>
</div>`;
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 = `<div style="color:#888;font-size:0.7rem;margin-top:2px;">
${fmt(p.position.ns)}N, ${fmt(p.position.ew)}E z=${fmt(p.position.z)}
</div>`;
}
row.innerHTML = `
<div style="display:flex;align-items:center;gap:6px;margin-bottom:3px;">
${dot}
<strong style="flex:1;">${p.character_name}</strong>
${subscribed}
</div>
<div style="color:#888;font-size:0.7rem;margin-bottom:3px;">tags: ${tags}</div>
${vitalsHtml}
${posHtml}
`;
listDiv.appendChild(row);
});
}