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