From a7e2d4d40498bc5c1decd9e21ce8caf5f85d2e45 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 13 Mar 2026 08:59:43 +0100 Subject: [PATCH] feat: stream live equipment cantrip states --- main.py | 46 +++++++++++++++ static/script.js | 147 ++++++++++++++--------------------------------- 2 files changed, 90 insertions(+), 103 deletions(-) diff --git a/main.py b/main.py index 1d8e8229..2fdb8422 100644 --- a/main.py +++ b/main.py @@ -979,6 +979,7 @@ app = FastAPI() live_snapshots: Dict[str, dict] = {} live_vitals: Dict[str, dict] = {} live_character_stats: Dict[str, dict] = {} +live_equipment_cantrip_states: Dict[str, dict] = {} # Shared secret used to authenticate plugin WebSocket connections (override for production) SHARED_SECRET = "your_shared_secret" @@ -2131,6 +2132,7 @@ async def ws_receive_snapshots( name = data.get("character_name") or data.get("player_name") if isinstance(name, str): plugin_conns[name] = websocket + live_equipment_cantrip_states.pop(name, None) logger.info(f"📋 PLUGIN_REGISTERED: {name} from {websocket.client}") continue # --- Spawn event: persist to spawn_events table --- @@ -2526,6 +2528,22 @@ async def ws_receive_snapshots( exc_info=True, ) continue + # --- Equipment cantrip state: live-only overlay for mana panel --- + if msg_type == "equipment_cantrip_state": + try: + character_name = data.get("character_name") + if character_name: + live_equipment_cantrip_states[character_name] = data + await _broadcast_to_browser_clients(data) + logger.debug( + f"Updated equipment cantrip state for {character_name}" + ) + except Exception as e: + logger.error( + f"Failed to process equipment_cantrip_state for {data.get('character_name', 'unknown')}: {e}", + exc_info=True, + ) + continue # --- Quest message: update cache and broadcast (no database storage) --- if msg_type == "quest": character_name = data.get("character_name") @@ -2639,6 +2657,12 @@ async def ws_receive_snapshots( finally: # Track plugin disconnection _plugin_connections = max(0, _plugin_connections - 1) + disconnected_names = [ + name for name, ws in plugin_conns.items() if ws is websocket + ] + for name in disconnected_names: + plugin_conns.pop(name, None) + live_equipment_cantrip_states.pop(name, None) # Clean up any plugin registrations for this socket to_remove = [n for n, ws in plugin_conns.items() if ws is websocket] @@ -3035,6 +3059,28 @@ async def get_character_stats(name: str): raise HTTPException(status_code=500, detail="Internal server error") +@app.get("/equipment-cantrip-state/{name}") +async def get_equipment_cantrip_state(name: str): + """Return latest live equipment cantrip state overlay for a character.""" + try: + data = live_equipment_cantrip_states.get(name) + if data: + return JSONResponse(content=jsonable_encoder(data)) + + return JSONResponse( + content={ + "type": "equipment_cantrip_state", + "character_name": name, + "items": [], + } + ) + except Exception as e: + logger.error( + f"Failed to get equipment cantrip state for {name}: {e}", exc_info=True + ) + raise HTTPException(status_code=500, detail="Internal server error") + + # -------------------- static frontend --------------------------- # Custom icon handler that prioritizes clean icons over originals from fastapi.responses import FileResponse diff --git a/static/script.js b/static/script.js index 348cfcf9..31749877 100644 --- a/static/script.js +++ b/static/script.js @@ -309,6 +309,7 @@ const chatWindows = {}; const statsWindows = {}; // Keep track of open inventory windows: character_name -> DOM element const inventoryWindows = {}; +const equipmentCantripStates = {}; /** * ---------- Application Constants ----------------------------- @@ -1491,29 +1492,35 @@ function renderInventoryState(state) { function getManaTrackedItems(state) { if (!state || !state.items) return []; + const overlayItems = equipmentCantripStates[state.characterName]?.items; + const overlayMap = new Map(); + if (Array.isArray(overlayItems)) { + overlayItems.forEach(item => { + if (item && item.item_id != null) { + overlayMap.set(Number(item.item_id), item); + } + }); + } + const snapshotMs = Date.now(); return state.items .filter(item => (item.current_wielded_location || 0) > 0) - .filter(item => !isAetheriaItem(item)) - .filter(item => { - const spellInfo = item.spells; - const hasSpellData = - (Array.isArray(spellInfo?.spells) && spellInfo.spells.length > 0) || - (Array.isArray(spellInfo?.active_spells) && spellInfo.active_spells.length > 0) || - Number(spellInfo?.spell_count || 0) > 0 || - Number(spellInfo?.active_spell_count || 0) > 0; - - return ( - item.is_mana_tracked || - item.current_mana !== undefined || - item.max_mana !== undefined || - item.spellcraft !== undefined || - hasSpellData - ); - }) + .filter(item => overlayMap.has(Number(item.item_id))) .map(item => { const result = { ...item }; - result.mana_state = deriveManaStateFromCharacterStats(result, state.characterName); + const overlay = overlayMap.get(Number(item.item_id)) || {}; + result.mana_state = overlay.state || 'unknown'; + if (overlay.current_mana !== undefined && overlay.current_mana !== null) { + result.current_mana = overlay.current_mana; + } + if (overlay.max_mana !== undefined && overlay.max_mana !== null) { + result.max_mana = overlay.max_mana; + } + if (overlay.mana_time_remaining_seconds !== undefined && overlay.mana_time_remaining_seconds !== null) { + result.mana_time_remaining_seconds = overlay.mana_time_remaining_seconds; + result.mana_snapshot_utc = equipmentCantripStates[state.characterName]?.timestamp || null; + } + if (result.mana_time_remaining_seconds !== undefined && result.mana_time_remaining_seconds !== null) { const snapshotUtc = result.mana_snapshot_utc ? Date.parse(result.mana_snapshot_utc) : NaN; if (!Number.isNaN(snapshotUtc)) { @@ -1538,91 +1545,6 @@ function getManaTrackedItems(state) { }); } -function isActionableManaSpell(spell) { - if (!spell) return false; - const spellName = (spell.name || '').toLowerCase(); - if (!spellName || spellName.startsWith('unknown_spell_')) return false; - if (spellName.startsWith('cantrip portal send') || spellName.startsWith('cantrip portal recall')) return false; - if (spellName.startsWith('incantation of ') || spellName.startsWith('aura of incantation ')) return true; - if ( - spellName.startsWith('feeble ') || - spellName.startsWith('minor ') || - spellName.startsWith('lesser ') || - spellName.startsWith('moderate ') || - spellName.startsWith('inner ') || - spellName.startsWith('major ') || - spellName.startsWith('epic ') || - spellName.startsWith('legendary ') || - spellName.startsWith('prodigal ') - ) return true; - - const duration = spell.duration; - return duration !== undefined && duration !== null && Number(duration) <= 0; -} - -function doesSpellMatch(activeSpell, spell) { - if (!activeSpell || !spell) return false; - if (activeSpell.id === spell.id) return true; - if (activeSpell.family == null || spell.family == null) return false; - if (Number(activeSpell.family) !== Number(spell.family)) return false; - if (activeSpell.difficulty == null || spell.difficulty == null) return true; - return Number(activeSpell.difficulty) >= Number(spell.difficulty); -} - -function isHandEquippedItem(item) { - const mask = Number(item?.current_wielded_location || 0); - return ( - mask === 1048576 || - mask === 2097152 || - mask === 4194304 || - mask === 16777216 || - mask === 33554432 - ); -} - -function isAetheriaItem(item) { - const mask = Number(item?.current_wielded_location || 0); - return mask === 268435456 || mask === 536870912 || mask === 1073741824; -} - -function deriveManaStateFromCharacterStats(item, characterName) { - const stats = characterStats[characterName]; - const activeEnchantments = stats?.active_item_enchantments; - const itemActiveSpells = Array.isArray(item.spells?.active_spells) ? item.spells.active_spells : []; - - if ((!Array.isArray(activeEnchantments) || activeEnchantments.length === 0) && itemActiveSpells.length === 0) { - return item.mana_state; - } - - if (item.current_mana === undefined || item.current_mana === null) return item.mana_state; - if (item.current_mana <= 0) return 'not_active'; - - const translatedSpells = Array.isArray(item.spells?.spells) ? item.spells.spells : []; - const actionableSpells = translatedSpells.filter(isActionableManaSpell); - if (actionableSpells.length === 0) return item.mana_state; - - const allMatchedOnItem = actionableSpells.every(spell => itemActiveSpells.some(activeSpell => doesSpellMatch(activeSpell, spell))); - if (allMatchedOnItem) { - return 'active'; - } - - if (!Array.isArray(activeEnchantments) || activeEnchantments.length === 0) { - if (isHandEquippedItem(item)) { - return 'not_active'; - } - return allMatchedOnItem ? 'active' : item.mana_state; - } - - const allMatched = actionableSpells.every(spell => { - if (!spell) return false; - return activeEnchantments.some(activeSpell => doesSpellMatch(activeSpell, spell)); - }); - - if (allMatched) return 'active'; - if (item.mana_state === 'active') return 'not_active'; - return item.mana_state || 'not_active'; -} - function formatManaRemaining(totalSeconds) { if (totalSeconds === null || totalSeconds === undefined) return '--'; const safeSeconds = Math.max(0, Math.floor(totalSeconds)); @@ -1883,6 +1805,20 @@ function showInventoryWindow(name) { .catch(() => {}); } + if (!equipmentCantripStates[name]) { + fetch(`${API_BASE}/equipment-cantrip-state/${encodeURIComponent(name)}`) + .then(r => r.ok ? r.json() : null) + .then(data => { + if (data && !data.error) { + equipmentCantripStates[name] = data; + if (win._inventoryState) { + renderInventoryState(win._inventoryState); + } + } + }) + .catch(() => {}); + } + debugLog('Inventory window created for:', name); } @@ -3102,6 +3038,11 @@ function initWebSocket() { if (inventoryWindows[msg.character_name] && inventoryWindows[msg.character_name]._inventoryState) { renderInventoryState(inventoryWindows[msg.character_name]._inventoryState); } + } else if (msg.type === 'equipment_cantrip_state') { + equipmentCantripStates[msg.character_name] = msg; + if (inventoryWindows[msg.character_name] && inventoryWindows[msg.character_name]._inventoryState) { + renderInventoryState(inventoryWindows[msg.character_name]._inventoryState); + } } else if (msg.type === 'inventory_delta') { updateInventoryLive(msg); } else if (msg.type === 'server_status') {