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"}
|
||||
|
||||
|
||||
@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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue