feat: stream live equipment cantrip states
This commit is contained in:
parent
0cb8e2f75a
commit
a7e2d4d404
2 changed files with 90 additions and 103 deletions
46
main.py
46
main.py
|
|
@ -979,6 +979,7 @@ app = FastAPI()
|
||||||
live_snapshots: Dict[str, dict] = {}
|
live_snapshots: Dict[str, dict] = {}
|
||||||
live_vitals: Dict[str, dict] = {}
|
live_vitals: Dict[str, dict] = {}
|
||||||
live_character_stats: 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 used to authenticate plugin WebSocket connections (override for production)
|
||||||
SHARED_SECRET = "your_shared_secret"
|
SHARED_SECRET = "your_shared_secret"
|
||||||
|
|
@ -2131,6 +2132,7 @@ async def ws_receive_snapshots(
|
||||||
name = data.get("character_name") or data.get("player_name")
|
name = data.get("character_name") or data.get("player_name")
|
||||||
if isinstance(name, str):
|
if isinstance(name, str):
|
||||||
plugin_conns[name] = websocket
|
plugin_conns[name] = websocket
|
||||||
|
live_equipment_cantrip_states.pop(name, None)
|
||||||
logger.info(f"📋 PLUGIN_REGISTERED: {name} from {websocket.client}")
|
logger.info(f"📋 PLUGIN_REGISTERED: {name} from {websocket.client}")
|
||||||
continue
|
continue
|
||||||
# --- Spawn event: persist to spawn_events table ---
|
# --- Spawn event: persist to spawn_events table ---
|
||||||
|
|
@ -2526,6 +2528,22 @@ async def ws_receive_snapshots(
|
||||||
exc_info=True,
|
exc_info=True,
|
||||||
)
|
)
|
||||||
continue
|
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) ---
|
# --- Quest message: update cache and broadcast (no database storage) ---
|
||||||
if msg_type == "quest":
|
if msg_type == "quest":
|
||||||
character_name = data.get("character_name")
|
character_name = data.get("character_name")
|
||||||
|
|
@ -2639,6 +2657,12 @@ async def ws_receive_snapshots(
|
||||||
finally:
|
finally:
|
||||||
# Track plugin disconnection
|
# Track plugin disconnection
|
||||||
_plugin_connections = max(0, _plugin_connections - 1)
|
_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
|
# 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]
|
||||||
|
|
@ -3035,6 +3059,28 @@ async def get_character_stats(name: str):
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
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 ---------------------------
|
# -------------------- static frontend ---------------------------
|
||||||
# Custom icon handler that prioritizes clean icons over originals
|
# Custom icon handler that prioritizes clean icons over originals
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
|
|
|
||||||
147
static/script.js
147
static/script.js
|
|
@ -309,6 +309,7 @@ const chatWindows = {};
|
||||||
const statsWindows = {};
|
const statsWindows = {};
|
||||||
// Keep track of open inventory windows: character_name -> DOM element
|
// Keep track of open inventory windows: character_name -> DOM element
|
||||||
const inventoryWindows = {};
|
const inventoryWindows = {};
|
||||||
|
const equipmentCantripStates = {};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ---------- Application Constants -----------------------------
|
* ---------- Application Constants -----------------------------
|
||||||
|
|
@ -1491,29 +1492,35 @@ function renderInventoryState(state) {
|
||||||
function getManaTrackedItems(state) {
|
function getManaTrackedItems(state) {
|
||||||
if (!state || !state.items) return [];
|
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();
|
const snapshotMs = Date.now();
|
||||||
return state.items
|
return state.items
|
||||||
.filter(item => (item.current_wielded_location || 0) > 0)
|
.filter(item => (item.current_wielded_location || 0) > 0)
|
||||||
.filter(item => !isAetheriaItem(item))
|
.filter(item => overlayMap.has(Number(item.item_id)))
|
||||||
.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
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.map(item => {
|
.map(item => {
|
||||||
const result = { ...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) {
|
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;
|
const snapshotUtc = result.mana_snapshot_utc ? Date.parse(result.mana_snapshot_utc) : NaN;
|
||||||
if (!Number.isNaN(snapshotUtc)) {
|
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) {
|
function formatManaRemaining(totalSeconds) {
|
||||||
if (totalSeconds === null || totalSeconds === undefined) return '--';
|
if (totalSeconds === null || totalSeconds === undefined) return '--';
|
||||||
const safeSeconds = Math.max(0, Math.floor(totalSeconds));
|
const safeSeconds = Math.max(0, Math.floor(totalSeconds));
|
||||||
|
|
@ -1883,6 +1805,20 @@ function showInventoryWindow(name) {
|
||||||
.catch(() => {});
|
.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);
|
debugLog('Inventory window created for:', name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3102,6 +3038,11 @@ function initWebSocket() {
|
||||||
if (inventoryWindows[msg.character_name] && inventoryWindows[msg.character_name]._inventoryState) {
|
if (inventoryWindows[msg.character_name] && inventoryWindows[msg.character_name]._inventoryState) {
|
||||||
renderInventoryState(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') {
|
} else if (msg.type === 'inventory_delta') {
|
||||||
updateInventoryLive(msg);
|
updateInventoryLive(msg);
|
||||||
} else if (msg.type === 'server_status') {
|
} else if (msg.type === 'server_status') {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue