feat: stream live equipment cantrip states

This commit is contained in:
Erik 2026-03-13 09:02:44 +01:00
parent e20f9df256
commit 0fbd100b7b
6 changed files with 477 additions and 10 deletions

View file

@ -0,0 +1,419 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Windows.Forms;
using Decal.Adapter;
using Decal.Adapter.Wrappers;
using Decal.Filters;
namespace MosswartMassacre
{
internal sealed class EquipmentCantripStateTracker : IDisposable
{
private readonly IPluginLogger _logger;
private readonly Timer _debounceTimer;
private readonly Dictionary<int, DateTime> _lastManaUpdateByItemId = new Dictionary<int, DateTime>();
internal EquipmentCantripStateTracker(IPluginLogger logger)
{
_logger = logger;
_debounceTimer = new Timer();
_debounceTimer.Interval = 250;
_debounceTimer.Tick += DebounceTimer_Tick;
}
internal void Initialize()
{
try
{
foreach (WorldObject wo in CoreManager.Current.WorldFilter.GetInventory())
{
RememberManaSnapshotTime(wo);
}
RequestRefresh();
}
catch (Exception ex)
{
_logger?.Log($"[EquipCantrip] Initialize error: {ex.Message}");
}
}
internal void Cleanup()
{
_debounceTimer.Stop();
_lastManaUpdateByItemId.Clear();
}
internal void OnCreateObject(object sender, CreateObjectEventArgs e)
{
try
{
if (!IsPotentiallyRelevant(e.New))
return;
RememberManaSnapshotTime(e.New);
RequestRefresh();
}
catch (Exception ex)
{
_logger?.Log($"[EquipCantrip] Create error: {ex.Message}");
}
}
internal void OnReleaseObject(object sender, ReleaseObjectEventArgs e)
{
try
{
if (_lastManaUpdateByItemId.Remove(e.Released.Id) || IsPotentiallyRelevant(e.Released))
{
RequestRefresh();
}
}
catch (Exception ex)
{
_logger?.Log($"[EquipCantrip] Release error: {ex.Message}");
}
}
internal void OnChangeObject(object sender, ChangeObjectEventArgs e)
{
try
{
if (e.Change == WorldChangeType.IdentReceived || e.Change == WorldChangeType.ManaChange)
{
RememberManaSnapshotTime(e.Changed);
}
if (IsPotentiallyRelevant(e.Changed) || _lastManaUpdateByItemId.ContainsKey(e.Changed.Id))
{
RequestRefresh();
}
}
catch (Exception ex)
{
_logger?.Log($"[EquipCantrip] Change error: {ex.Message}");
}
}
internal void OnChangeEnchantments(object sender, ChangeEnchantmentsEventArgs e)
{
RequestRefresh();
}
private void DebounceTimer_Tick(object sender, EventArgs e)
{
_debounceTimer.Stop();
SendSnapshot();
}
private void RequestRefresh()
{
_debounceTimer.Stop();
_debounceTimer.Start();
}
private void SendSnapshot()
{
try
{
if (!PluginCore.WebSocketEnabled)
return;
var items = BuildSnapshotItems();
_ = WebSocket.SendEquipmentCantripStateAsync(items.ToArray());
}
catch (Exception ex)
{
_logger?.Log($"[EquipCantrip] Send error: {ex.Message}");
}
}
private List<object> BuildSnapshotItems()
{
var snapshotItems = new List<object>();
FileService fileService = null;
try
{
fileService = CoreManager.Current.Filter<FileService>();
if (fileService?.SpellTable == null)
return snapshotItems;
var activeCharacterSpells = GetActiveCharacterItemSpellData(fileService);
foreach (WorldObject wo in CoreManager.Current.WorldFilter.GetInventory())
{
if (!ShouldDisplayInManaPanel(wo))
continue;
var snapshot = BuildItemSnapshot(wo, fileService, activeCharacterSpells);
if (snapshot != null)
{
snapshotItems.Add(snapshot);
}
}
}
catch (Exception ex)
{
_logger?.Log($"[EquipCantrip] Snapshot build error: {ex.Message}");
}
return snapshotItems;
}
private List<Spell> GetActiveCharacterItemSpellData(FileService fileService)
{
var activeSpells = new List<Spell>();
var cf = CoreManager.Current.CharacterFilter;
try
{
int enchantmentCount = cf.Underlying.EnchantmentCount;
for (int i = 0; i < enchantmentCount; i++)
{
Decal.Interop.Filters.Enchantment enchantment = null;
try
{
enchantment = cf.Underlying.get_Enchantment(i);
if (enchantment == null || enchantment.TimeRemaining > 0)
continue;
var spell = fileService.SpellTable.GetById(enchantment.SpellID);
if (spell != null)
{
activeSpells.Add(spell);
}
}
finally
{
if (enchantment != null)
{
System.Runtime.InteropServices.Marshal.ReleaseComObject(enchantment);
}
}
}
}
catch (Exception ex)
{
_logger?.Log($"[EquipCantrip] Active enchantment read error: {ex.Message}");
}
return activeSpells;
}
private object BuildItemSnapshot(WorldObject wo, FileService fileService, List<Spell> activeCharacterSpells)
{
var itemState = RecalculateState(wo, fileService, activeCharacterSpells);
if (itemState == EquipmentCantripItemState.NotActivatable)
return null;
int maximumMana = wo.Values(LongValueKey.MaximumMana, 0);
int currentMana = itemState == EquipmentCantripItemState.Unknown ? 0 : CalculateCurrentMana(wo, itemState);
double? manaTimeRemainingSeconds = null;
if (itemState == EquipmentCantripItemState.Active)
{
var remaining = CalculateManaTimeRemaining(wo, currentMana);
manaTimeRemainingSeconds = remaining.TotalSeconds;
}
return new
{
item_id = wo.Id,
state = ToWireState(itemState),
current_mana = itemState == EquipmentCantripItemState.Unknown ? (int?)null : currentMana,
max_mana = itemState == EquipmentCantripItemState.Unknown ? (int?)null : maximumMana,
mana_time_remaining_seconds = manaTimeRemainingSeconds
};
}
private EquipmentCantripItemState RecalculateState(WorldObject wo, FileService fileService, List<Spell> activeCharacterSpells)
{
if (wo == null || !wo.HasIdData)
return EquipmentCantripItemState.Unknown;
if (wo.SpellCount == 0 || wo.Values(LongValueKey.MaximumMana, 0) == 0)
return EquipmentCantripItemState.NotActivatable;
if (wo.Values(LongValueKey.CurrentMana, 0) == 0)
return EquipmentCantripItemState.NotActive;
for (int i = 0; i < wo.SpellCount; i++)
{
int spellOnItemId = wo.Spell(i);
if (wo.Exists(LongValueKey.AssociatedSpell) && wo.Values(LongValueKey.AssociatedSpell) == spellOnItemId)
continue;
var spellOnItem = fileService.SpellTable.GetById(spellOnItemId);
if (spellOnItem == null)
continue;
if (spellOnItem.IsDebuff || spellOnItem.IsOffensive)
continue;
bool thisSpellIsActive = false;
for (int j = 0; j < wo.ActiveSpellCount; j++)
{
var activeSpellOnItem = fileService.SpellTable.GetById(wo.ActiveSpell(j));
if (SpellMatchesOrSurpasses(activeSpellOnItem, spellOnItem))
{
thisSpellIsActive = true;
break;
}
}
if (thisSpellIsActive)
continue;
foreach (var activeSpellOnChar in activeCharacterSpells)
{
if (SpellMatchesOrSurpasses(activeSpellOnChar, spellOnItem))
{
thisSpellIsActive = true;
break;
}
}
if (!thisSpellIsActive)
return EquipmentCantripItemState.NotActive;
}
return EquipmentCantripItemState.Active;
}
private static bool SpellMatchesOrSurpasses(Spell activeSpell, Spell requiredSpell)
{
if (activeSpell == null || requiredSpell == null)
return false;
return activeSpell.Family == requiredSpell.Family && activeSpell.Difficulty >= requiredSpell.Difficulty;
}
private int CalculateCurrentMana(WorldObject wo, EquipmentCantripItemState itemState)
{
if (wo == null)
return 0;
if (itemState == EquipmentCantripItemState.Unknown || itemState == EquipmentCantripItemState.NotActivatable)
return 0;
if (itemState == EquipmentCantripItemState.NotActive)
return wo.Values(LongValueKey.CurrentMana, 0);
var secondsPerBurn = GetSecondsPerBurn(wo);
if (secondsPerBurn <= 0)
return wo.Values(LongValueKey.CurrentMana, 0);
if (!_lastManaUpdateByItemId.TryGetValue(wo.Id, out var lastUpdate))
{
lastUpdate = DateTime.UtcNow;
_lastManaUpdateByItemId[wo.Id] = lastUpdate;
}
var timeSinceLastUpdate = DateTime.UtcNow - lastUpdate;
int burnedMana = (int)(timeSinceLastUpdate.TotalSeconds / secondsPerBurn);
int currentMana = wo.Values(LongValueKey.CurrentMana, 0);
if (burnedMana > currentMana)
burnedMana = currentMana;
return Math.Max(currentMana - burnedMana, 0);
}
private TimeSpan CalculateManaTimeRemaining(WorldObject wo, int calculatedCurrentMana)
{
var secondsPerBurn = GetSecondsPerBurn(wo);
if (secondsPerBurn <= 0)
return new TimeSpan(99, 99, 0);
return TimeSpan.FromSeconds(calculatedCurrentMana * secondsPerBurn);
}
private double GetSecondsPerBurn(WorldObject wo)
{
if (wo == null || !wo.HasIdData)
return 0;
double manaRateOfChange = wo.Values(DoubleValueKey.ManaRateOfChange, 0);
if (manaRateOfChange == 0)
return 0;
return ((int)Math.Ceiling(-0.2 / manaRateOfChange)) * 5;
}
private void RememberManaSnapshotTime(WorldObject wo)
{
if (wo == null)
return;
_lastManaUpdateByItemId[wo.Id] = DateTime.UtcNow;
}
private bool IsPotentiallyRelevant(WorldObject wo)
{
return wo != null && (ItemIsEquippedByMe(wo) || _lastManaUpdateByItemId.ContainsKey(wo.Id));
}
private bool ShouldDisplayInManaPanel(WorldObject wo)
{
if (wo == null || !ItemIsEquippedByMe(wo))
return false;
if (!string.IsNullOrEmpty(wo.Name) && wo.Name.IndexOf("Aetheria", StringComparison.OrdinalIgnoreCase) >= 0)
return false;
if (wo.Values(LongValueKey.EquipableSlots, 0) == 134217728)
return false;
if (wo.Values(LongValueKey.EquippedSlots, 0) == 8388608)
return false;
return true;
}
private bool ItemIsEquippedByMe(WorldObject wo)
{
int equippedSlots = wo.Values(LongValueKey.EquippedSlots, 0);
if (equippedSlots <= 0)
return false;
if (wo.Values(LongValueKey.Slot, -1) == -1)
return wo.Container == CoreManager.Current.CharacterFilter.Id;
return true;
}
private static string ToWireState(EquipmentCantripItemState itemState)
{
switch (itemState)
{
case EquipmentCantripItemState.Active:
return "active";
case EquipmentCantripItemState.NotActive:
return "not_active";
case EquipmentCantripItemState.NotActivatable:
return "not_activatable";
default:
return "unknown";
}
}
public void Dispose()
{
Cleanup();
_debounceTimer.Tick -= DebounceTimer_Tick;
_debounceTimer.Dispose();
}
private enum EquipmentCantripItemState
{
Unknown,
NotActivatable,
Active,
NotActive
}
}
}

View file

@ -2,6 +2,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Reflection; using System.Reflection;
using System.Runtime.InteropServices;
using Decal.Adapter; using Decal.Adapter;
using Decal.Adapter.Wrappers; using Decal.Adapter.Wrappers;
using Decal.Filters; using Decal.Filters;
@ -920,21 +921,29 @@ namespace MosswartMassacre
} }
// Scan active spells for cantrips using CharacterFilter.Enchantments // Scan active spells for cantrips using CharacterFilter.Enchantments
var enchantments = characterFilter.Enchantments; int enchantmentCount = characterFilter.Underlying.EnchantmentCount;
if (enchantments != null) for (int i = 0; i < enchantmentCount; i++)
{ {
for (int i = 0; i < enchantments.Count; i++) Decal.Interop.Filters.Enchantment ench = null;
try
{ {
var ench = enchantments[i]; ench = characterFilter.Underlying.get_Enchantment(i);
var spell = SpellManager.GetSpell(ench.SpellId); if (ench == null)
continue;
var spell = SpellManager.GetSpell(ench.SpellID);
if (spell != null && spell.CantripLevel != Mag.Shared.Spells.Spell.CantripLevels.None) if (spell != null && spell.CantripLevel != Mag.Shared.Spells.Spell.CantripLevels.None)
{ {
DetectCantrip(ench.SpellId); DetectCantrip(ench.SpellID);
}
}
finally
{
if (ench != null)
{
Marshal.ReleaseComObject(ench);
} }
} }
}
else
{
} }
// Compute final icon IDs for all cantrips after refresh // Compute final icon IDs for all cantrips after refresh
@ -1929,4 +1938,4 @@ namespace MosswartMassacre
} }
#endregion #endregion
} }
} }

View file

@ -317,6 +317,7 @@
<Compile Include="RareTracker.cs" /> <Compile Include="RareTracker.cs" />
<Compile Include="ClientTelemetry.cs" /> <Compile Include="ClientTelemetry.cs" />
<Compile Include="DecalHarmonyClean.cs" /> <Compile Include="DecalHarmonyClean.cs" />
<Compile Include="EquipmentCantripStateTracker.cs" />
<Compile Include="FlagTrackerData.cs" /> <Compile Include="FlagTrackerData.cs" />
<Compile Include="MossyInventory.cs" /> <Compile Include="MossyInventory.cs" />
<Compile Include="NavRoute.cs" /> <Compile Include="NavRoute.cs" />

View file

@ -145,6 +145,7 @@ namespace MosswartMassacre
private QuestStreamingService _questStreamingService; private QuestStreamingService _questStreamingService;
private CommandRouter _commandRouter; private CommandRouter _commandRouter;
private LiveInventoryTracker _liveInventoryTracker; private LiveInventoryTracker _liveInventoryTracker;
private EquipmentCantripStateTracker _equipmentCantripStateTracker;
protected override void Startup() protected override void Startup()
{ {
@ -188,6 +189,13 @@ namespace MosswartMassacre
CoreManager.Current.WorldFilter.ReleaseObject -= _liveInventoryTracker.OnReleaseObject; CoreManager.Current.WorldFilter.ReleaseObject -= _liveInventoryTracker.OnReleaseObject;
CoreManager.Current.WorldFilter.ChangeObject -= _liveInventoryTracker.OnChangeObject; CoreManager.Current.WorldFilter.ChangeObject -= _liveInventoryTracker.OnChangeObject;
} }
if (_equipmentCantripStateTracker != null)
{
CoreManager.Current.WorldFilter.CreateObject -= _equipmentCantripStateTracker.OnCreateObject;
CoreManager.Current.WorldFilter.ReleaseObject -= _equipmentCantripStateTracker.OnReleaseObject;
CoreManager.Current.WorldFilter.ChangeObject -= _equipmentCantripStateTracker.OnChangeObject;
CoreManager.Current.CharacterFilter.ChangeEnchantments -= _equipmentCantripStateTracker.OnChangeEnchantments;
}
if (_gameEventRouter != null) if (_gameEventRouter != null)
CoreManager.Current.EchoFilter.ServerDispatch -= _gameEventRouter.OnServerDispatch; CoreManager.Current.EchoFilter.ServerDispatch -= _gameEventRouter.OnServerDispatch;
WebSocket.OnServerCommand -= HandleServerCommand; WebSocket.OnServerCommand -= HandleServerCommand;
@ -227,6 +235,7 @@ namespace MosswartMassacre
// Initialize live inventory tracker (delta WebSocket messages) // Initialize live inventory tracker (delta WebSocket messages)
_liveInventoryTracker = new LiveInventoryTracker(this); _liveInventoryTracker = new LiveInventoryTracker(this);
_equipmentCantripStateTracker = new EquipmentCantripStateTracker(this);
// Initialize chat event router (rareTracker set later in LoginComplete) // Initialize chat event router (rareTracker set later in LoginComplete)
_chatEventRouter = new ChatEventRouter( _chatEventRouter = new ChatEventRouter(
@ -253,6 +262,10 @@ namespace MosswartMassacre
CoreManager.Current.WorldFilter.CreateObject += _liveInventoryTracker.OnCreateObject; CoreManager.Current.WorldFilter.CreateObject += _liveInventoryTracker.OnCreateObject;
CoreManager.Current.WorldFilter.ReleaseObject += _liveInventoryTracker.OnReleaseObject; CoreManager.Current.WorldFilter.ReleaseObject += _liveInventoryTracker.OnReleaseObject;
CoreManager.Current.WorldFilter.ChangeObject += _liveInventoryTracker.OnChangeObject; CoreManager.Current.WorldFilter.ChangeObject += _liveInventoryTracker.OnChangeObject;
CoreManager.Current.WorldFilter.CreateObject += _equipmentCantripStateTracker.OnCreateObject;
CoreManager.Current.WorldFilter.ReleaseObject += _equipmentCantripStateTracker.OnReleaseObject;
CoreManager.Current.WorldFilter.ChangeObject += _equipmentCantripStateTracker.OnChangeObject;
CoreManager.Current.CharacterFilter.ChangeEnchantments += _equipmentCantripStateTracker.OnChangeEnchantments;
// Initialize VVS view after character login // Initialize VVS view after character login
ViewManager.ViewInit(); ViewManager.ViewInit();
@ -425,6 +438,15 @@ namespace MosswartMassacre
CoreManager.Current.WorldFilter.ChangeObject -= _liveInventoryTracker.OnChangeObject; CoreManager.Current.WorldFilter.ChangeObject -= _liveInventoryTracker.OnChangeObject;
_liveInventoryTracker.Cleanup(); _liveInventoryTracker.Cleanup();
} }
if (_equipmentCantripStateTracker != null)
{
CoreManager.Current.WorldFilter.CreateObject -= _equipmentCantripStateTracker.OnCreateObject;
CoreManager.Current.WorldFilter.ReleaseObject -= _equipmentCantripStateTracker.OnReleaseObject;
CoreManager.Current.WorldFilter.ChangeObject -= _equipmentCantripStateTracker.OnChangeObject;
CoreManager.Current.CharacterFilter.ChangeEnchantments -= _equipmentCantripStateTracker.OnChangeEnchantments;
_equipmentCantripStateTracker.Dispose();
_equipmentCantripStateTracker = null;
}
// Clean up Harmony patches // Clean up Harmony patches
DecalHarmonyClean.Cleanup(); DecalHarmonyClean.Cleanup();
@ -514,6 +536,7 @@ namespace MosswartMassacre
// Initialize live inventory tracking (after full inventory dump) // Initialize live inventory tracking (after full inventory dump)
_liveInventoryTracker?.Initialize(); _liveInventoryTracker?.Initialize();
_equipmentCantripStateTracker?.Initialize();
// Initialize quest manager for always-on quest streaming // Initialize quest manager for always-on quest streaming
try try
@ -641,6 +664,7 @@ namespace MosswartMassacre
// 7b. Reinitialize live inventory tracking // 7b. Reinitialize live inventory tracking
_liveInventoryTracker?.Initialize(); _liveInventoryTracker?.Initialize();
_equipmentCantripStateTracker?.Initialize();
// 8. Reinitialize quest manager for hot reload // 8. Reinitialize quest manager for hot reload
try try

View file

@ -335,6 +335,20 @@ namespace MosswartMassacre
await SendEncodedAsync(json, CancellationToken.None); await SendEncodedAsync(json, CancellationToken.None);
} }
public static async Task SendEquipmentCantripStateAsync(object[] items)
{
var envelope = new
{
type = "equipment_cantrip_state",
timestamp = DateTime.UtcNow.ToString("o"),
character_name = CoreManager.Current.CharacterFilter.Name,
items = items
};
var json = JsonConvert.SerializeObject(envelope);
await SendEncodedAsync(json, CancellationToken.None);
}
public static async Task SendQuestDataAsync(string questName, string countdown) public static async Task SendQuestDataAsync(string questName, string countdown)
{ {
var envelope = new var envelope = new