diff --git a/MosswartMassacre/MosswartMassacre.csproj b/MosswartMassacre/MosswartMassacre.csproj index b88c2a5..f6eb87a 100644 --- a/MosswartMassacre/MosswartMassacre.csproj +++ b/MosswartMassacre/MosswartMassacre.csproj @@ -236,6 +236,10 @@ lib\VirindiViewService.dll + + lib\UtilityBelt.Helper.dll + False + ..\packages\YamlDotNet.16.3.0\lib\net47\YamlDotNet.dll @@ -349,6 +353,7 @@ + diff --git a/MosswartMassacre/PluginCore.cs b/MosswartMassacre/PluginCore.cs index f8a132f..f7d2ba7 100644 --- a/MosswartMassacre/PluginCore.cs +++ b/MosswartMassacre/PluginCore.cs @@ -147,6 +147,7 @@ namespace MosswartMassacre private LiveInventoryTracker _liveInventoryTracker; private EquipmentCantripStateTracker _equipmentCantripStateTracker; private NearbyObjectsTracker _nearbyObjectsTracker; + private VitalSharingTracker _vitalSharingTracker; protected override void Startup() { @@ -200,6 +201,7 @@ namespace MosswartMassacre if (_gameEventRouter != null) CoreManager.Current.EchoFilter.ServerDispatch -= _gameEventRouter.OnServerDispatch; WebSocket.OnServerCommand -= HandleServerCommand; + WebSocket.OnServerMessage -= HandleServerMessage; // Stop old timers before recreating (prevents timer leaks on hot reload) _killTracker?.Stop(); @@ -299,6 +301,7 @@ namespace MosswartMassacre WebSocket.SetGameStats(this); //lyssna på commands WebSocket.OnServerCommand += HandleServerCommand; + WebSocket.OnServerMessage += HandleServerMessage; //starta inventory. Hanterar subscriptions i den med _inventoryLogger = new MossyInventory(); @@ -309,6 +312,9 @@ namespace MosswartMassacre // Initialize nearby objects tracker (radar) _nearbyObjectsTracker = new NearbyObjectsTracker(this); + // Initialize vital sharing tracker (cross-machine vital/debuff coordination) + _vitalSharingTracker = new VitalSharingTracker(this); + // Initialize command router _commandRouter = new CommandRouter(); RegisterCommands(); @@ -418,12 +424,17 @@ namespace MosswartMassacre _nearbyObjectsTracker?.Dispose(); _nearbyObjectsTracker = null; + // Stop vital sharing tracker + _vitalSharingTracker?.Dispose(); + _vitalSharingTracker = null; + // Clean up the view ViewManager.ViewDestroy(); //Disable vtank interface vTank.Disable(); // sluta lyssna på commands WebSocket.OnServerCommand -= HandleServerCommand; + WebSocket.OnServerMessage -= HandleServerMessage; WebSocket.Stop(); //shutdown inv _inventoryLogger.Dispose(); @@ -505,6 +516,13 @@ namespace MosswartMassacre if (WebSocketEnabled) WebSocket.Start(); + // Auto-start vital sharing if enabled in settings + if (PluginSettings.Instance.VitalSharingEnabled && _vitalSharingTracker != null) + { + _vitalSharingTracker.Start(); + WriteToChat("[VitalShare] Enabled at login"); + } + if (PluginSettings.Instance.AutoUpdateEnabled) { _updateCheckTimer = new Timer(30000); @@ -895,6 +913,21 @@ namespace MosswartMassacre } } + private void HandleServerMessage(string rawJson) + { + // This is called from WebSocket thread. + // VitalSharingTracker.HandleIncomingShareMessage uses its own lock and + // defers actual VTank API calls to the RenderFrame drain. + try + { + _vitalSharingTracker?.HandleIncomingShareMessage(rawJson); + } + catch (Exception ex) + { + WriteToChat($"[VitalShare] HandleServerMessage error: {ex.Message}"); + } + } + private void ProcessPendingCommands(object sender, EventArgs e) { // This runs on the main UI thread via Windows Forms timer @@ -1112,6 +1145,102 @@ namespace MosswartMassacre } }, "Enable/disable WebSocket streaming"); + _commandRouter.Register("vitalsharing", args => + { + if (args.Length < 2) + { + WriteToChat("Usage: /mm vitalsharing |tag remove |tags>"); + return; + } + + var sub = args[1].ToLowerInvariant(); + + if (sub == "on") + { + if (!WebSocketEnabled) + { + WriteToChat("[VitalShare] WebSocket must be enabled first. Run /mm ws enable"); + return; + } + PluginSettings.Instance.VitalSharingEnabled = true; + _vitalSharingTracker?.Start(); + WriteToChat("[VitalShare] ENABLED (will stream to Overlord)"); + } + else if (sub == "off") + { + PluginSettings.Instance.VitalSharingEnabled = false; + _vitalSharingTracker?.Stop(); + WriteToChat("[VitalShare] DISABLED"); + } + else if (sub == "status") + { + var enabled = PluginSettings.Instance.VitalSharingEnabled; + var active = _vitalSharingTracker?.IsActive == true; + var tags = PluginSettings.Instance.VitalSharingTags; + var tagStr = (tags == null || tags.Count == 0) ? "(none)" : string.Join(", ", tags); + WriteToChat($"[VitalShare] enabled={enabled} active={active} tags={tagStr}"); + } + else if (sub == "tags") + { + var tags = PluginSettings.Instance.VitalSharingTags; + if (tags == null || tags.Count == 0) + WriteToChat("[VitalShare] No tags set"); + else + WriteToChat("[VitalShare] Tags: " + string.Join(", ", tags)); + } + else if (sub == "tag") + { + if (args.Length < 4) + { + WriteToChat("Usage: /mm vitalsharing tag "); + return; + } + var action = args[2].ToLowerInvariant(); + var name = args[3].Trim(); + if (string.IsNullOrEmpty(name)) + { + WriteToChat("Tag name cannot be empty"); + return; + } + var tags = PluginSettings.Instance.VitalSharingTags ?? new System.Collections.Generic.List(); + if (action == "add") + { + if (!tags.Contains(name, StringComparer.OrdinalIgnoreCase)) + { + tags.Add(name); + PluginSettings.Instance.VitalSharingTags = tags; + WriteToChat($"[VitalShare] Added tag: {name}"); + } + else + { + WriteToChat($"[VitalShare] Tag already set: {name}"); + } + } + else if (action == "remove") + { + var match = tags.Find(t => t.Equals(name, StringComparison.OrdinalIgnoreCase)); + if (match != null) + { + tags.Remove(match); + PluginSettings.Instance.VitalSharingTags = tags; + WriteToChat($"[VitalShare] Removed tag: {name}"); + } + else + { + WriteToChat($"[VitalShare] Tag not found: {name}"); + } + } + else + { + WriteToChat("Usage: /mm vitalsharing tag "); + } + } + else + { + WriteToChat("Usage: /mm vitalsharing |tag remove |tags>"); + } + }, "Enable/disable vital sharing with other MosswartOverlord clients"); + _commandRouter.Register("report", args => { TimeSpan elapsed = DateTime.Now - _killTracker.StatsStartTime; diff --git a/MosswartMassacre/PluginSettings.cs b/MosswartMassacre/PluginSettings.cs index c411073..db0716a 100644 --- a/MosswartMassacre/PluginSettings.cs +++ b/MosswartMassacre/PluginSettings.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using YamlDotNet.Serialization; using YamlDotNet.Serialization.NamingConventions; @@ -23,6 +24,8 @@ namespace MosswartMassacre private string _vtankProfilesPath = ""; private bool _verboseLogging = false; private bool _autoUpdateEnabled = true; + private bool _vitalSharingEnabled = false; + private List _vitalSharingTags = new List(); private ChestLooterSettings _chestLooterSettings = new ChestLooterSettings(); public static PluginSettings Instance => _instance @@ -196,6 +199,22 @@ namespace MosswartMassacre set { _autoUpdateEnabled = value; Save(); } } + public bool VitalSharingEnabled + { + get => _vitalSharingEnabled; + set { _vitalSharingEnabled = value; Save(); } + } + + public List VitalSharingTags + { + get + { + if (_vitalSharingTags == null) _vitalSharingTags = new List(); + return _vitalSharingTags; + } + set { _vitalSharingTags = value ?? new List(); Save(); } + } + public ChestLooterSettings ChestLooterSettings { get diff --git a/MosswartMassacre/VitalSharingTracker.cs b/MosswartMassacre/VitalSharingTracker.cs new file mode 100644 index 0000000..d58c12f --- /dev/null +++ b/MosswartMassacre/VitalSharingTracker.cs @@ -0,0 +1,653 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using System.Text.RegularExpressions; +using uTank2; +using Decal.Adapter; +using Decal.Adapter.Wrappers; +using Decal.Filters; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UBHelper; + +namespace MosswartMassacre +{ + /// + /// Cross-machine replacement for UB's Networking + VTankFellowHeals. Shares + /// vitals, positions, items, and debuff cast coordination between characters + /// via MosswartOverlord's WebSocket pipeline. Received messages are queued + /// onto the game main thread and fed into UBHelper.vTank.Instance so that + /// VTank's internal state matches what local UB sharing would produce. + /// + /// Activation is opt-in via PluginSettings.VitalSharingEnabled. Default off. + /// + /// Runs on System.Windows.Forms.Timer (UI thread) for DECAL COM safety. + /// + public class VitalSharingTracker : IDisposable + { + private const int VitalIntervalMs = 150; + private const int PositionIntervalMs = 300; + private const int ItemIntervalMs = 5000; + + private static readonly Regex ProcCastRegex = new Regex( + @"^You cast (?.+) on .+\.?$", + RegexOptions.Compiled); + + private readonly IPluginLogger _logger; + private readonly Queue _networkActionQueue = new Queue(); + private readonly object _queueLock = new object(); + + private System.Windows.Forms.Timer _timer; + private bool _active; + private bool _disposed; + + // Last-sent dedup state (vitals/position/items only send on change) + private int _lastHealth = -1, _lastMaxHealth = -1; + private int _lastStamina = -1, _lastMaxStamina = -1; + private int _lastMana = -1, _lastMaxMana = -1; + private double _lastEW, _lastNS, _lastZ; + private double _lastHeading; + private DateTime _lastVitalSendUtc = DateTime.MinValue; + private DateTime _lastPositionSendUtc = DateTime.MinValue; + private DateTime _lastItemSendUtc = DateTime.MinValue; + + // Cast attempt dedup — UB uses 1200ms to avoid spamming repeat casts + private int _lastAttemptedSpellId; + private int _lastAttemptedTarget; + private DateTime _lastCastAttempt = DateTime.MinValue; + private int _lastCastSuccessSpellId; + private DateTime _lastCastSuccess = DateTime.MinValue; + + public bool IsActive => _active; + + public VitalSharingTracker(IPluginLogger logger) + { + _logger = logger; + } + + public void Start() + { + if (_active) return; + _active = true; + + // Subscribe to packet and chat hooks on the main thread + try + { + CoreManager.Current.EchoFilter.ClientDispatch += EchoFilter_ClientDispatch; + CoreManager.Current.ChatBoxMessage += Core_ChatBoxMessage; + CoreManager.Current.RenderFrame += Core_RenderFrame; + } + catch (Exception ex) + { + _logger?.Log($"[VitalShare] Hook subscribe error: {ex.Message}"); + } + + _timer = new System.Windows.Forms.Timer(); + _timer.Interval = VitalIntervalMs; // tick at vital cadence, other intervals gated internally + _timer.Tick += OnTick; + _timer.Start(); + + // Announce to backend that we're opted in so it knows to forward shared events to us + _ = WebSocket.SendVitalShareAsync(new + { + type = "share_subscribe", + timestamp = DateTime.UtcNow.ToString("o"), + character_name = SafeCharacterName(), + player_id = SafePlayerId(), + tags = GetTagsArray(), + }); + + _logger?.Log("[VitalShare] Started vital sharing tracker"); + } + + public void Stop() + { + if (!_active) return; + _active = false; + + try + { + CoreManager.Current.EchoFilter.ClientDispatch -= EchoFilter_ClientDispatch; + CoreManager.Current.ChatBoxMessage -= Core_ChatBoxMessage; + CoreManager.Current.RenderFrame -= Core_RenderFrame; + } + catch { } + + if (_timer != null) + { + _timer.Stop(); + _timer.Tick -= OnTick; + _timer.Dispose(); + _timer = null; + } + + // Tell backend to stop forwarding shared events to us + try + { + _ = WebSocket.SendVitalShareAsync(new + { + type = "share_unsubscribe", + timestamp = DateTime.UtcNow.ToString("o"), + character_name = SafeCharacterName(), + }); + } + catch { } + + _logger?.Log("[VitalShare] Stopped vital sharing tracker"); + } + + // Called by PluginCore when a server command arrives that looks like a share_* message. + // Runs on UI thread via the command queue. + public void HandleIncomingShareMessage(string rawJson) + { + if (!_active) return; + try + { + var root = JObject.Parse(rawJson); + var type = (string)root["type"]; + if (string.IsNullOrEmpty(type)) return; + + switch (type) + { + case "share_vital_update": + HandleShareVitalUpdate(root); + break; + case "share_cast_attempt": + HandleShareCastAttempt(root); + break; + case "share_cast_success": + HandleShareCastSuccess(root); + break; + case "share_position_update": + case "share_item_update": + // Dashboard informational only. No VTank feeding. + break; + } + } + catch (Exception ex) + { + _logger?.Log($"[VitalShare] HandleIncomingShareMessage error: {ex.Message}"); + } + } + + private void HandleShareVitalUpdate(JObject root) + { + // Skip if the update is for our own character (backend should filter but be safe) + string fromChar = (string)root["character_name"] ?? ""; + if (fromChar.Equals(SafeCharacterName(), StringComparison.OrdinalIgnoreCase)) + return; + + int playerId = (int?)root["player_id"] ?? 0; + int curH = (int?)root["current_health"] ?? 0; + int maxH = (int?)root["max_health"] ?? 0; + int curS = (int?)root["current_stamina"] ?? 0; + int maxS = (int?)root["max_stamina"] ?? 0; + int curM = (int?)root["current_mana"] ?? 0; + int maxM = (int?)root["max_mana"] ?? 0; + if (playerId == 0 || maxH == 0) return; + + Enqueue(() => + { + try + { + if (vTank.Instance == null) return; + var info = new sPlayerInfoUpdate + { + PlayerID = playerId, + HasHealthInfo = true, + HasManaInfo = true, + HasStamInfo = true, + curHealth = curH, + curMana = curM, + curStam = curS, + maxHealth = maxH, + maxMana = maxM, + maxStam = maxS, + }; + vTank.Instance.HelperPlayerUpdate(info); + } + catch (Exception ex) { _logger?.Log($"[VitalShare] HelperPlayerUpdate error: {ex.Message}"); } + }); + } + + private void HandleShareCastAttempt(JObject root) + { + string fromChar = (string)root["character_name"] ?? ""; + if (fromChar.Equals(SafeCharacterName(), StringComparison.OrdinalIgnoreCase)) + return; + + int spellId = (int?)root["spell_id"] ?? 0; + int target = (int?)root["target_id"] ?? 0; + double skill = (double?)root["skill"] ?? 0; + if (spellId == 0 || target == 0) return; + + Enqueue(() => + { + try + { + if (vTank.Instance == null) return; + if (!CoreManager.Current.Actions.IsValidObject(target)) return; + vTank.Instance.LogCastAttempt(spellId, target, (int)skill); + } + catch (Exception ex) { _logger?.Log($"[VitalShare] LogCastAttempt error: {ex.Message}"); } + }); + } + + private void HandleShareCastSuccess(JObject root) + { + string fromChar = (string)root["character_name"] ?? ""; + if (fromChar.Equals(SafeCharacterName(), StringComparison.OrdinalIgnoreCase)) + return; + + int spellId = (int?)root["spell_id"] ?? 0; + int target = (int?)root["target_id"] ?? 0; + int duration = (int?)root["duration_ms"] ?? 0; + if (spellId == 0 || target == 0) return; + + Enqueue(() => + { + try + { + if (vTank.Instance == null) return; + if (!CoreManager.Current.Actions.IsValidObject(target)) return; + vTank.Instance.LogSpellCast(target, spellId, duration); + } + catch (Exception ex) { _logger?.Log($"[VitalShare] LogSpellCast error: {ex.Message}"); } + }); + } + + private void Enqueue(Action action) + { + lock (_queueLock) + { + _networkActionQueue.Enqueue(action); + } + } + + private void Core_RenderFrame(object sender, EventArgs e) + { + if (!_active) return; + // Drain queued VTank API calls on the main thread + while (true) + { + Action next = null; + lock (_queueLock) + { + if (_networkActionQueue.Count == 0) break; + next = _networkActionQueue.Dequeue(); + } + try { next?.Invoke(); } + catch (Exception ex) { _logger?.Log($"[VitalShare] Queued action error: {ex.Message}"); } + } + } + + private void OnTick(object sender, EventArgs e) + { + if (!_active) return; + try + { + // Send vitals on every tick (150ms) if changed + TrySendVitalUpdate(false); + + var now = DateTime.UtcNow; + if (now - _lastPositionSendUtc >= TimeSpan.FromMilliseconds(PositionIntervalMs)) + TrySendPositionUpdate(false); + if (now - _lastItemSendUtc >= TimeSpan.FromMilliseconds(ItemIntervalMs)) + TrySendItemUpdate(false); + } + catch (Exception ex) + { + _logger?.Log($"[VitalShare] OnTick error: {ex.Message}"); + } + } + + private void TrySendVitalUpdate(bool force) + { + try + { + int curH = CoreManager.Current.Actions.Vital[VitalType.CurrentHealth]; + int maxH = CoreManager.Current.Actions.Vital[VitalType.MaximumHealth]; + int curS = CoreManager.Current.Actions.Vital[VitalType.CurrentStamina]; + int maxS = CoreManager.Current.Actions.Vital[VitalType.MaximumStamina]; + int curM = CoreManager.Current.Actions.Vital[VitalType.CurrentMana]; + int maxM = CoreManager.Current.Actions.Vital[VitalType.MaximumMana]; + + bool changed = curH != _lastHealth || maxH != _lastMaxHealth + || curS != _lastStamina || maxS != _lastMaxStamina + || curM != _lastMana || maxM != _lastMaxMana; + // Heartbeat every 3s even when unchanged so late subscribers see something + bool heartbeat = (DateTime.UtcNow - _lastVitalSendUtc) > TimeSpan.FromSeconds(3); + if (!force && !changed && !heartbeat) return; + + _lastHealth = curH; _lastMaxHealth = maxH; + _lastStamina = curS; _lastMaxStamina = maxS; + _lastMana = curM; _lastMaxMana = maxM; + _lastVitalSendUtc = DateTime.UtcNow; + + var payload = new + { + type = "share_vital_update", + timestamp = DateTime.UtcNow.ToString("o"), + character_name = SafeCharacterName(), + player_id = SafePlayerId(), + tags = GetTagsArray(), + current_health = curH, + max_health = maxH, + current_stamina = curS, + max_stamina = maxS, + current_mana = curM, + max_mana = maxM, + }; + _ = WebSocket.SendVitalShareAsync(payload); + } + catch (Exception ex) + { + _logger?.Log($"[VitalShare] TrySendVitalUpdate error: {ex.Message}"); + } + } + + private void TrySendPositionUpdate(bool force) + { + try + { + var me = Coordinates.Me; + double heading = CoreManager.Current.Actions.Heading; + + bool changed = Math.Abs(me.EW - _lastEW) > 0.00001 + || Math.Abs(me.NS - _lastNS) > 0.00001 + || Math.Abs(me.Z - _lastZ) > 0.1 + || Math.Abs(heading - _lastHeading) > 0.5; + bool heartbeat = (DateTime.UtcNow - _lastPositionSendUtc) > TimeSpan.FromSeconds(10); + if (!force && !changed && !heartbeat) return; + + _lastEW = me.EW; _lastNS = me.NS; _lastZ = me.Z; _lastHeading = heading; + _lastPositionSendUtc = DateTime.UtcNow; + + var payload = new + { + type = "share_position_update", + timestamp = DateTime.UtcNow.ToString("o"), + character_name = SafeCharacterName(), + player_id = SafePlayerId(), + tags = GetTagsArray(), + ew = Math.Round(me.EW, 7), + ns = Math.Round(me.NS, 7), + z = Math.Round(me.Z, 2), + heading = Math.Round(heading, 1), + }; + _ = WebSocket.SendVitalShareAsync(payload); + } + catch (Exception ex) + { + _logger?.Log($"[VitalShare] TrySendPositionUpdate error: {ex.Message}"); + } + } + + private void TrySendItemUpdate(bool force) + { + // Placeholder — item count tracking mirrors UB's TrackedItemUpdateMessage. + // For now we only send a heartbeat-style update so the dashboard knows we're alive. + try + { + if (!force && (DateTime.UtcNow - _lastItemSendUtc) < TimeSpan.FromSeconds(ItemIntervalMs / 1000.0)) + return; + _lastItemSendUtc = DateTime.UtcNow; + + var payload = new + { + type = "share_item_update", + timestamp = DateTime.UtcNow.ToString("o"), + character_name = SafeCharacterName(), + player_id = SafePlayerId(), + tags = GetTagsArray(), + }; + _ = WebSocket.SendVitalShareAsync(payload); + } + catch (Exception ex) + { + _logger?.Log($"[VitalShare] TrySendItemUpdate error: {ex.Message}"); + } + } + + private void EchoFilter_ClientDispatch(object sender, NetworkMessageEventArgs e) + { + if (!_active) return; + try + { + // Magic_CastTargetedSpell (0xF7B1 action 0x004A) — outgoing spell cast packet + if (e.Message.Type != 0xF7B1) return; + int action = e.Message.Value("action"); + if (action != 0x004A) return; + + int target = e.Message.Value("target"); + int spellId = e.Message.Value("spell"); + + // 1200ms dedup (same as UB) + if (_lastAttemptedSpellId == spellId && _lastAttemptedTarget == target + && (DateTime.UtcNow - _lastCastAttempt) < TimeSpan.FromMilliseconds(1200)) + return; + + if (!IsDebuffSpell(spellId)) return; + + double skill = GetEffectiveSkillForSpell(spellId); + + _lastCastAttempt = DateTime.UtcNow; + _lastAttemptedSpellId = spellId; + _lastAttemptedTarget = target; + + var payload = new + { + type = "share_cast_attempt", + timestamp = DateTime.UtcNow.ToString("o"), + character_name = SafeCharacterName(), + player_id = SafePlayerId(), + tags = GetTagsArray(), + spell_id = spellId, + target_id = target, + skill = skill, + }; + _ = WebSocket.SendVitalShareAsync(payload); + } + catch (Exception ex) + { + _logger?.Log($"[VitalShare] EchoFilter_ClientDispatch error: {ex.Message}"); + } + } + + private void Core_ChatBoxMessage(object sender, ChatTextInterceptEventArgs e) + { + if (!_active) return; + try + { + if (!e.Text.StartsWith("You cast ")) return; + + int spellId; + int target; + + if (_lastAttemptedSpellId != 0) + { + // Normal cast — we already know what was cast from the outgoing packet + if (_lastCastSuccessSpellId == _lastAttemptedSpellId + && (DateTime.UtcNow - _lastCastSuccess) < TimeSpan.FromMilliseconds(50)) + return; + + if (!IsDebuffSpell(_lastAttemptedSpellId)) return; + + spellId = _lastAttemptedSpellId; + target = _lastAttemptedTarget; + } + else + { + // Proc debuff — parse spell name from chat + var match = ProcCastRegex.Match(e.Text); + if (!match.Success) return; + var parsedName = match.Groups["spellName"].Value; + spellId = GetSpellIdByName(parsedName); + if (spellId == 0) return; + if (!IsDebuffSpell(spellId)) return; + if (_lastCastSuccessSpellId == spellId + && (DateTime.UtcNow - _lastCastSuccess) < TimeSpan.FromMilliseconds(50)) + return; + target = CoreManager.Current.Actions.CurrentSelection; + if (target == 0) return; + } + + _lastCastSuccess = DateTime.UtcNow; + _lastCastSuccessSpellId = spellId; + int durationMs = GetSpellDurationMs(spellId); + + var payload = new + { + type = "share_cast_success", + timestamp = DateTime.UtcNow.ToString("o"), + character_name = SafeCharacterName(), + player_id = SafePlayerId(), + tags = GetTagsArray(), + spell_id = spellId, + target_id = target, + duration_ms = durationMs, + }; + _ = WebSocket.SendVitalShareAsync(payload); + } + catch (Exception ex) + { + _logger?.Log($"[VitalShare] Core_ChatBoxMessage error: {ex.Message}"); + } + } + + // --- Spell helpers using DECAL's FileService.SpellTable --- + + private static readonly string[] DebuffNameTokens = new[] + { + "Vulnerability", "Imperil", "Succumb", + "Weakness", "Feeblemind", "Dispirit", "Impotence", + "Slowness", "Clumsiness", "Frailty", "Bafflement", + "Tranquility", + // Elemental vulns + "Fire Vulnerability", "Cold Vulnerability", + "Acid Vulnerability", "Lightning Vulnerability", + "Blade Vulnerability", "Pierce Vulnerability", "Bludgeon Vulnerability", + // Defense debuffs + "Defenselessness", "Open Mind", + }; + + private bool IsDebuffSpell(int spellId) + { + try + { + var spell = CoreManager.Current.Filter()?.SpellTable?.GetById(spellId); + if (spell == null) return false; + string name = spell.Name ?? ""; + foreach (var token in DebuffNameTokens) + { + if (name.IndexOf(token, StringComparison.OrdinalIgnoreCase) >= 0) + return true; + } + return false; + } + catch { return false; } + } + + // Cache of spell name → id, built lazily on first use + private static Dictionary _spellNameCache; + + private int GetSpellIdByName(string name) + { + try + { + if (_spellNameCache == null) + { + _spellNameCache = new Dictionary(StringComparer.OrdinalIgnoreCase); + var fs = CoreManager.Current.Filter(); + if (fs?.SpellTable == null) return 0; + // AC spell IDs cluster in a few ranges — scan up to 10000 + for (int id = 1; id < 10000; id++) + { + try + { + var s = fs.SpellTable.GetById(id); + if (s != null && !string.IsNullOrEmpty(s.Name) && !_spellNameCache.ContainsKey(s.Name)) + _spellNameCache[s.Name] = id; + } + catch { } + } + } + return _spellNameCache.TryGetValue(name, out var result) ? result : 0; + } + catch { } + return 0; + } + + private int GetSpellDurationMs(int spellId) + { + try + { + var spell = CoreManager.Current.Filter()?.SpellTable?.GetById(spellId); + if (spell == null) return 0; + // DECAL's Spell.Duration is in seconds as a double — convert to ms + return (int)(spell.Duration * 1000); + } + catch { return 0; } + } + + private double GetEffectiveSkillForSpell(int spellId) + { + try + { + var spell = CoreManager.Current.Filter()?.SpellTable?.GetById(spellId); + if (spell == null) return 0; + var cf = CoreManager.Current.CharacterFilter; + // DECAL's Spell.School is the SpellSchool enum. Compare by ToString so we don't + // depend on specific numeric values. + switch (spell.School.ToString()) + { + case "CreatureEnchantment": + return cf.EffectiveSkill[CharFilterSkillType.CreatureEnchantment]; + case "ItemEnchantment": + return cf.EffectiveSkill[CharFilterSkillType.ItemEnchantment]; + case "LifeMagic": + return cf.EffectiveSkill[CharFilterSkillType.LifeMagic]; + case "WarMagic": + return cf.EffectiveSkill[CharFilterSkillType.WarMagic]; + case "VoidMagic": + return cf.EffectiveSkill[CharFilterSkillType.VoidMagic]; + default: + return cf.EffectiveSkill[CharFilterSkillType.ItemEnchantment]; + } + } + catch { return 0; } + } + + // --- Helpers --- + + private string SafeCharacterName() + { + try { return CoreManager.Current.CharacterFilter.Name ?? ""; } + catch { return ""; } + } + + private int SafePlayerId() + { + try { return CoreManager.Current.CharacterFilter.Id; } + catch { return 0; } + } + + private string[] GetTagsArray() + { + try + { + var tags = PluginSettings.Instance.VitalSharingTags; + return tags != null ? tags.ToArray() : new string[0]; + } + catch { return new string[0]; } + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + Stop(); + } + } +} diff --git a/MosswartMassacre/WebSocket.cs b/MosswartMassacre/WebSocket.cs index 27c8387..058254b 100644 --- a/MosswartMassacre/WebSocket.cs +++ b/MosswartMassacre/WebSocket.cs @@ -51,6 +51,13 @@ namespace MosswartMassacre /// public static event Action OnServerCommand; + /// + /// Fires when any other typed JSON message arrives from the server + /// (e.g. share_vital_update, share_cast_attempt). Passes the raw JSON + /// string — the handler is responsible for parsing based on the "type" field. + /// + public static event Action OnServerMessage; + // ─── public API ───────────────────────────── public static void SetLogger(IPluginLogger logger) => _logger = logger; @@ -132,19 +139,40 @@ namespace MosswartMassacre var msg = Encoding.UTF8.GetString(buffer, 0, result.Count).Trim(); - // 3) Parse into CommandEnvelope - CommandEnvelope env; + // 3) Peek at the message type to route appropriately + Newtonsoft.Json.Linq.JObject root = null; try { - env = JsonConvert.DeserializeObject(msg); + root = Newtonsoft.Json.Linq.JObject.Parse(msg); } catch (JsonException) { continue; // skip malformed JSON } - // 4) Filter by this character name - if (string.Equals( + var msgType = (string)root["type"]; + + // Route share_* messages via OnServerMessage — the tracker filters + // out messages from its own character, so no player_name check here + if (!string.IsNullOrEmpty(msgType) && msgType.StartsWith("share_")) + { + try { OnServerMessage?.Invoke(msg); } + catch (Exception ex) { _logger?.Log($"[WebSocket] OnServerMessage error: {ex.Message}"); } + continue; + } + + // 4) Otherwise treat as CommandEnvelope filtered by character name + CommandEnvelope env; + try + { + env = root.ToObject(); + } + catch (JsonException) + { + continue; + } + + if (env != null && string.Equals( env.PlayerName, CoreManager.Current.CharacterFilter.Name, StringComparison.OrdinalIgnoreCase)) @@ -329,6 +357,17 @@ namespace MosswartMassacre await SendEncodedAsync(json, CancellationToken.None); } + /// + /// Send a vital-sharing event (share_vital_update, share_cast_attempt, etc.) + /// These are forwarded by the backend to all other opted-in plugin clients + /// so VTank's state can be synchronized across machines. + /// + public static async Task SendVitalShareAsync(object payload) + { + var json = JsonConvert.SerializeObject(payload); + await SendEncodedAsync(json, CancellationToken.None); + } + public static async Task SendCharacterStatsAsync(object statsData) { var json = JsonConvert.SerializeObject(statsData); diff --git a/MosswartMassacre/bin/Release/MosswartMassacre.dll b/MosswartMassacre/bin/Release/MosswartMassacre.dll index 1b478ff..0c18f58 100644 Binary files a/MosswartMassacre/bin/Release/MosswartMassacre.dll and b/MosswartMassacre/bin/Release/MosswartMassacre.dll differ diff --git a/MosswartMassacre/lib/UtilityBelt.Helper.dll b/MosswartMassacre/lib/UtilityBelt.Helper.dll new file mode 100644 index 0000000..e8c00bd Binary files /dev/null and b/MosswartMassacre/lib/UtilityBelt.Helper.dll differ