feat: cross-machine vital/debuff sharing via MosswartOverlord

Reimplements UtilityBelt VTankFellowHeals + Networking using Overlord's
WebSocket pipeline so debuff coordination and vital sharing work across
multiple PCs instead of only localhost.

- VitalSharingTracker: packet hook (0xF7B1/0x004A) for cast attempts,
  chat regex for cast success (handles proc debuffs), 150ms vital timer,
  300ms position timer, 5s item timer. Inbound share_* queued to main
  thread via Core.RenderFrame and routed to UBHelper.vTank.Instance
  (LogCastAttempt / LogSpellCast / HelperPlayerUpdate).
- WebSocket: new OnServerMessage event, share_* routing in receive loop,
  SendVitalShareAsync helper.
- PluginSettings: VitalSharingEnabled (default off) + VitalSharingTags.
- PluginCore: /mm vitalsharing on|off|status|tag add/remove|tags.
- csproj: reference UtilityBelt.Helper.dll (sPlayerInfoUpdate actually
  lives in utank2-i.dll under the uTank2 namespace).

Running UB local sharing and MM cross-PC sharing simultaneously is safe
since VTank's Log* methods are idempotent.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-11 14:19:32 +02:00
parent a40974ea67
commit b87412e8f9
7 changed files with 850 additions and 5 deletions

View file

@ -236,6 +236,10 @@
<Reference Include="VirindiViewService">
<HintPath>lib\VirindiViewService.dll</HintPath>
</Reference>
<Reference Include="UtilityBelt.Helper">
<HintPath>lib\UtilityBelt.Helper.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="YamlDotNet, Version=16.0.0.0, Culture=neutral, PublicKeyToken=ec19458f3c15af5e, processorArchitecture=MSIL">
<HintPath>..\packages\YamlDotNet.16.3.0\lib\net47\YamlDotNet.dll</HintPath>
</Reference>
@ -349,6 +353,7 @@
<Compile Include="CharacterStats.cs" />
<Compile Include="DungeonMapReader.cs" />
<Compile Include="NearbyObjectsTracker.cs" />
<Compile Include="VitalSharingTracker.cs" />
<Compile Include="WebSocket.cs" />
</ItemGroup>
<ItemGroup>

View file

@ -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 <on|off|status|tag add <name>|tag remove <name>|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 <add|remove> <name>");
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<string>();
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 <add|remove> <name>");
}
}
else
{
WriteToChat("Usage: /mm vitalsharing <on|off|status|tag add <name>|tag remove <name>|tags>");
}
}, "Enable/disable vital sharing with other MosswartOverlord clients");
_commandRouter.Register("report", args =>
{
TimeSpan elapsed = DateTime.Now - _killTracker.StatsStartTime;

View file

@ -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<string> _vitalSharingTags = new List<string>();
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<string> VitalSharingTags
{
get
{
if (_vitalSharingTags == null) _vitalSharingTags = new List<string>();
return _vitalSharingTags;
}
set { _vitalSharingTags = value ?? new List<string>(); Save(); }
}
public ChestLooterSettings ChestLooterSettings
{
get

View file

@ -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
{
/// <summary>
/// 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.
/// </summary>
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 (?<spellName>.+) on .+\.?$",
RegexOptions.Compiled);
private readonly IPluginLogger _logger;
private readonly Queue<Action> _networkActionQueue = new Queue<Action>();
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<int>("action");
if (action != 0x004A) return;
int target = e.Message.Value<int>("target");
int spellId = e.Message.Value<int>("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<FileService>()?.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<string, int> _spellNameCache;
private int GetSpellIdByName(string name)
{
try
{
if (_spellNameCache == null)
{
_spellNameCache = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var fs = CoreManager.Current.Filter<FileService>();
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<FileService>()?.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<FileService>()?.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();
}
}
}

View file

@ -51,6 +51,13 @@ namespace MosswartMassacre
/// </summary>
public static event Action<CommandEnvelope> OnServerCommand;
/// <summary>
/// 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.
/// </summary>
public static event Action<string> 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<CommandEnvelope>(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<CommandEnvelope>();
}
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);
}
/// <summary>
/// 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.
/// </summary>
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);

Binary file not shown.