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:
parent
a40974ea67
commit
b87412e8f9
7 changed files with 850 additions and 5 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
653
MosswartMassacre/VitalSharingTracker.cs
Normal file
653
MosswartMassacre/VitalSharingTracker.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
BIN
MosswartMassacre/lib/UtilityBelt.Helper.dll
Normal file
BIN
MosswartMassacre/lib/UtilityBelt.Helper.dll
Normal file
Binary file not shown.
Loading…
Add table
Add a link
Reference in a new issue