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:
parent
52d57c9121
commit
1973aa1547
3 changed files with 292 additions and 0 deletions
134
main.py
134
main.py
|
|
@ -1714,6 +1714,25 @@ async def delete_issue(issue_id: str):
|
||||||
return {"status": "ok"}
|
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")
|
@app.get("/server-health")
|
||||||
async def get_server_health():
|
async def get_server_health():
|
||||||
"""Return current server health status."""
|
"""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
|
# Mapping of plugin clients by character_name to their WebSocket for command forwarding
|
||||||
plugin_conns: Dict[str, WebSocket] = {}
|
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:
|
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."""
|
"""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)
|
await _broadcast_to_browser_clients(data)
|
||||||
continue
|
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
|
# Unknown message types are ignored
|
||||||
if msg_type:
|
if msg_type:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
|
|
@ -3099,6 +3230,9 @@ async def ws_receive_snapshots(
|
||||||
plugin_conns.pop(name, None)
|
plugin_conns.pop(name, None)
|
||||||
live_equipment_cantrip_states.pop(name, None)
|
live_equipment_cantrip_states.pop(name, None)
|
||||||
live_nearby_objects.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
|
# Clean up any plugin registrations for this socket
|
||||||
to_remove = [n for n, ws in plugin_conns.items() if ws is websocket]
|
to_remove = [n for n, ws in plugin_conns.items() if ws is websocket]
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,13 @@
|
||||||
📋 Issues Board
|
📋 Issues Board
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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 -->
|
<!-- Container for sort and filter controls -->
|
||||||
<div id="sortButtons" class="sort-buttons"></div>
|
<div id="sortButtons" class="sort-buttons"></div>
|
||||||
|
|
|
||||||
151
static/script.js
151
static/script.js
|
|
@ -3074,6 +3074,8 @@ function initWebSocket() {
|
||||||
const rw = radarWindows[msg.character_name];
|
const rw = radarWindows[msg.character_name];
|
||||||
if (rw) rw._radarDungeonLandblock = msg.landblock;
|
if (rw) rw._radarDungeonLandblock = msg.landblock;
|
||||||
}
|
}
|
||||||
|
} else if (typeof msg.type === 'string' && msg.type.startsWith('share_')) {
|
||||||
|
updateVitalSharingPeer(msg);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
socket.addEventListener('close', () => setTimeout(initWebSocket, 2000));
|
socket.addEventListener('close', () => setTimeout(initWebSocket, 2000));
|
||||||
|
|
@ -4429,3 +4431,152 @@ function showCommentsSection(row, issue, win) {
|
||||||
section.appendChild(addDiv);
|
section.appendChild(addDiv);
|
||||||
row.appendChild(section);
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue