diff --git a/MosswartMassacre/EquipmentCantripStateTracker.cs b/MosswartMassacre/EquipmentCantripStateTracker.cs new file mode 100644 index 0000000..bfa2a2f --- /dev/null +++ b/MosswartMassacre/EquipmentCantripStateTracker.cs @@ -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 _lastManaUpdateByItemId = new Dictionary(); + + 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 BuildSnapshotItems() + { + var snapshotItems = new List(); + FileService fileService = null; + + try + { + fileService = CoreManager.Current.Filter(); + 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 GetActiveCharacterItemSpellData(FileService fileService) + { + var activeSpells = new List(); + 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 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 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 + } + } +} diff --git a/MosswartMassacre/FlagTrackerData.cs b/MosswartMassacre/FlagTrackerData.cs index 9ac261d..ad7a864 100644 --- a/MosswartMassacre/FlagTrackerData.cs +++ b/MosswartMassacre/FlagTrackerData.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Linq; using System.Reflection; +using System.Runtime.InteropServices; using Decal.Adapter; using Decal.Adapter.Wrappers; using Decal.Filters; @@ -920,21 +921,29 @@ namespace MosswartMassacre } // Scan active spells for cantrips using CharacterFilter.Enchantments - var enchantments = characterFilter.Enchantments; - if (enchantments != null) + int enchantmentCount = characterFilter.Underlying.EnchantmentCount; + 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]; - var spell = SpellManager.GetSpell(ench.SpellId); + ench = characterFilter.Underlying.get_Enchantment(i); + if (ench == null) + continue; + + var spell = SpellManager.GetSpell(ench.SpellID); 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 @@ -1929,4 +1938,4 @@ namespace MosswartMassacre } #endregion } -} \ No newline at end of file +} diff --git a/MosswartMassacre/MosswartMassacre.csproj b/MosswartMassacre/MosswartMassacre.csproj index 35c2a9a..49bb9f0 100644 --- a/MosswartMassacre/MosswartMassacre.csproj +++ b/MosswartMassacre/MosswartMassacre.csproj @@ -317,6 +317,7 @@ + diff --git a/MosswartMassacre/PluginCore.cs b/MosswartMassacre/PluginCore.cs index ac365f4..7eb11ff 100644 --- a/MosswartMassacre/PluginCore.cs +++ b/MosswartMassacre/PluginCore.cs @@ -145,6 +145,7 @@ namespace MosswartMassacre private QuestStreamingService _questStreamingService; private CommandRouter _commandRouter; private LiveInventoryTracker _liveInventoryTracker; + private EquipmentCantripStateTracker _equipmentCantripStateTracker; protected override void Startup() { @@ -188,6 +189,13 @@ namespace MosswartMassacre CoreManager.Current.WorldFilter.ReleaseObject -= _liveInventoryTracker.OnReleaseObject; 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) CoreManager.Current.EchoFilter.ServerDispatch -= _gameEventRouter.OnServerDispatch; WebSocket.OnServerCommand -= HandleServerCommand; @@ -227,6 +235,7 @@ namespace MosswartMassacre // Initialize live inventory tracker (delta WebSocket messages) _liveInventoryTracker = new LiveInventoryTracker(this); + _equipmentCantripStateTracker = new EquipmentCantripStateTracker(this); // Initialize chat event router (rareTracker set later in LoginComplete) _chatEventRouter = new ChatEventRouter( @@ -253,6 +262,10 @@ namespace MosswartMassacre CoreManager.Current.WorldFilter.CreateObject += _liveInventoryTracker.OnCreateObject; CoreManager.Current.WorldFilter.ReleaseObject += _liveInventoryTracker.OnReleaseObject; 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 ViewManager.ViewInit(); @@ -425,6 +438,15 @@ namespace MosswartMassacre CoreManager.Current.WorldFilter.ChangeObject -= _liveInventoryTracker.OnChangeObject; _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 DecalHarmonyClean.Cleanup(); @@ -514,6 +536,7 @@ namespace MosswartMassacre // Initialize live inventory tracking (after full inventory dump) _liveInventoryTracker?.Initialize(); + _equipmentCantripStateTracker?.Initialize(); // Initialize quest manager for always-on quest streaming try @@ -641,6 +664,7 @@ namespace MosswartMassacre // 7b. Reinitialize live inventory tracking _liveInventoryTracker?.Initialize(); + _equipmentCantripStateTracker?.Initialize(); // 8. Reinitialize quest manager for hot reload try diff --git a/MosswartMassacre/WebSocket.cs b/MosswartMassacre/WebSocket.cs index 4a07d82..dfdbf23 100644 --- a/MosswartMassacre/WebSocket.cs +++ b/MosswartMassacre/WebSocket.cs @@ -335,6 +335,20 @@ namespace MosswartMassacre 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) { var envelope = new diff --git a/MosswartMassacre/bin/Release/MosswartMassacre.dll b/MosswartMassacre/bin/Release/MosswartMassacre.dll index 4eb7303..5d5f46d 100644 Binary files a/MosswartMassacre/bin/Release/MosswartMassacre.dll and b/MosswartMassacre/bin/Release/MosswartMassacre.dll differ