diff --git a/Directory.Build.props b/Directory.Build.props
index 0f24f6d..5f5ccc0 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -15,7 +15,7 @@
- 0.1.18
+ 0.1.19-mmpreview.1
false
diff --git a/src/OpenAC.MosswartMassacre/Backend/BackendClient.cs b/src/OpenAC.MosswartMassacre/Backend/BackendClient.cs
index 0ad1949..5acd3c3 100644
--- a/src/OpenAC.MosswartMassacre/Backend/BackendClient.cs
+++ b/src/OpenAC.MosswartMassacre/Backend/BackendClient.cs
@@ -28,6 +28,7 @@ internal sealed class BackendClient : IDisposable
public const string SecretHeader = "X-Plugin-Secret";
private static readonly TimeSpan ReconnectDelay = TimeSpan.FromSeconds(2);
+ private static readonly TimeSpan FlushTimeout = TimeSpan.FromMilliseconds(500);
private readonly IPluginLogger _log;
private readonly ConcurrentQueue _inbound = new();
@@ -36,6 +37,7 @@ internal sealed class BackendClient : IDisposable
private CancellationTokenSource? _cts;
private Task? _loop;
private Channel? _outbound;
+ private Task? _sendLoop;
public BackendClient(IPluginLogger log)
{
@@ -79,18 +81,35 @@ internal sealed class BackendClient : IDisposable
{
Task? loop;
CancellationTokenSource? cts;
+ Channel? outbound;
+ Task? sendLoop;
lock (_gate)
{
if (_cts is null)
return;
cts = _cts;
_cts = null;
- cts.Cancel();
- _outbound?.Writer.TryComplete();
+ outbound = _outbound;
_outbound = null;
+ sendLoop = _sendLoop;
loop = _loop;
_loop = null;
}
+
+ // Let what was already queued go out (a farewell frame is sent just
+ // before stopping), briefly, then cut the socket.
+ if (outbound is not null)
+ {
+ outbound.Writer.TryComplete();
+ try
+ {
+ (sendLoop ?? outbound.Reader.Completion).Wait(FlushTimeout);
+ }
+ catch (AggregateException)
+ {
+ }
+ }
+ cts.Cancel();
try
{
loop?.Wait(TimeSpan.FromSeconds(2));
@@ -159,6 +178,8 @@ internal sealed class BackendClient : IDisposable
Task receive = ReceiveLoopAsync(socket, token);
Task send = SendLoopAsync(socket, outbound.Reader, token);
+ lock (_gate)
+ _sendLoop = send;
await Task.WhenAny(receive, send).ConfigureAwait(false);
}
catch (OperationCanceledException) when (token.IsCancellationRequested)
diff --git a/src/OpenAC.MosswartMassacre/FeatureCatalog.cs b/src/OpenAC.MosswartMassacre/FeatureCatalog.cs
index a8bbc08..ea14bac 100644
--- a/src/OpenAC.MosswartMassacre/FeatureCatalog.cs
+++ b/src/OpenAC.MosswartMassacre/FeatureCatalog.cs
@@ -29,5 +29,11 @@ internal static class FeatureCatalog
features.Add(new BotControl(context));
features.Add(new Views.MainWindow(context, stats));
session.AddConnectionListener(telemetry);
+
+ // Cross-computer vital and debuff sharing.
+ var sharing = new Sharing.VitalSharing(context);
+ features.Add(sharing);
+ session.AddConnectionListener(sharing);
+ session.AddShareHandler(sharing);
}
}
diff --git a/src/OpenAC.MosswartMassacre/MosswartMassacrePlugin.cs b/src/OpenAC.MosswartMassacre/MosswartMassacrePlugin.cs
index 6ab0d43..94aa757 100644
--- a/src/OpenAC.MosswartMassacre/MosswartMassacrePlugin.cs
+++ b/src/OpenAC.MosswartMassacre/MosswartMassacrePlugin.cs
@@ -131,8 +131,11 @@ public sealed class MosswartMassacrePlugin : IAcDreamPlugin
{
if (_context is null || _host is null || !_context.IsLoggedIn)
return;
- foreach (IMmFeature feature in _features)
+ // In reverse, so the socket (the first feature) closes last and the
+ // farewell frames the others send at logoff still go out.
+ for (int i = _features.Count - 1; i >= 0; i--)
{
+ IMmFeature feature = _features[i];
try
{
feature.OnLogoff();
diff --git a/src/OpenAC.MosswartMassacre/Sharing/PeerTable.cs b/src/OpenAC.MosswartMassacre/Sharing/PeerTable.cs
new file mode 100644
index 0000000..738e237
--- /dev/null
+++ b/src/OpenAC.MosswartMassacre/Sharing/PeerTable.cs
@@ -0,0 +1,81 @@
+namespace OpenAC.MosswartMassacre.Sharing;
+
+/// One character heard from through the backend.
+internal sealed class Peer
+{
+ public Peer(string characterName)
+ {
+ CharacterName = characterName;
+ }
+
+ public string CharacterName { get; }
+
+ public uint PlayerId { get; set; }
+
+ public int CurrentHealth { get; set; }
+
+ public int MaxHealth { get; set; }
+
+ public int CurrentStamina { get; set; }
+
+ public int MaxStamina { get; set; }
+
+ public int CurrentMana { get; set; }
+
+ public int MaxMana { get; set; }
+
+ public double EW { get; set; }
+
+ public double NS { get; set; }
+
+ /// Height in metres.
+ public double Z { get; set; }
+
+ public double Heading { get; set; }
+
+ public bool HasPosition { get; set; }
+
+ public IReadOnlyList Tags { get; set; } = [];
+
+ public DateTime LastUpdateUtc { get; set; }
+}
+
+///
+/// The peers heard from, by name. A peer not heard from for fifteen seconds
+/// drops out, the rule the host applies to its own peers; the original kept
+/// logged-off peers forever.
+///
+internal sealed class PeerTable
+{
+ public static readonly TimeSpan Staleness = TimeSpan.FromSeconds(15);
+
+ private readonly Dictionary _peers = new(StringComparer.OrdinalIgnoreCase);
+
+ public Peer Upsert(string name, DateTime nowUtc)
+ {
+ if (!_peers.TryGetValue(name, out Peer? peer))
+ {
+ peer = new Peer(name);
+ _peers[name] = peer;
+ }
+ peer.LastUpdateUtc = nowUtc;
+ return peer;
+ }
+
+ public bool TryGet(string name, out Peer? peer) => _peers.TryGetValue(name, out peer);
+
+ /// The peers heard from within the last fifteen seconds, by name.
+ public IReadOnlyList Current(DateTime nowUtc)
+ {
+ foreach (string stale in _peers.Values
+ .Where(peer => nowUtc - peer.LastUpdateUtc > Staleness)
+ .Select(peer => peer.CharacterName)
+ .ToList())
+ {
+ _peers.Remove(stale);
+ }
+ return [.. _peers.Values.OrderBy(peer => peer.CharacterName, StringComparer.OrdinalIgnoreCase)];
+ }
+
+ public void Clear() => _peers.Clear();
+}
diff --git a/src/OpenAC.MosswartMassacre/Sharing/VitalSharing.cs b/src/OpenAC.MosswartMassacre/Sharing/VitalSharing.cs
new file mode 100644
index 0000000..6141a54
--- /dev/null
+++ b/src/OpenAC.MosswartMassacre/Sharing/VitalSharing.cs
@@ -0,0 +1,597 @@
+using AcDream.Plugin.Abstractions;
+using Newtonsoft.Json.Linq;
+using OpenAC.MosswartMassacre.Backend;
+using OpenAC.MosswartMassacre.Infrastructure;
+
+namespace OpenAC.MosswartMassacre.Sharing;
+
+///
+/// Cross-computer vital and debuff sharing through the backend. Outgoing, it
+/// sends this character's vitals, position and debuff casts as the original's
+/// share_* frames. Incoming, it hands the other characters to the
+/// host's peer network, so the bot plugin sees them exactly as it sees
+/// characters on this computer: their vitals for heals, their debuffs for
+/// target choice.
+///
+///
+///
+/// The original fed VTank directly. On OpenAC the host's peer network is the
+/// meeting point: the bot announces its own casts there and reads everyone
+/// else's from there, and this feature carries both directions over the
+/// socket.
+///
+///
+/// Cast attempts are relayed for the casts the bot announces. The original
+/// saw every outgoing cast packet, including casts typed by hand; a plugin
+/// has no view of outgoing packets, so a hand-cast debuff is shared once it
+/// lands, not while it is being cast.
+///
+///
+internal sealed class VitalSharing : IMmFeature, IBackendConnectionListener, IShareFrameHandler
+{
+ public static readonly TimeSpan TickInterval = TimeSpan.FromMilliseconds(150);
+ private static readonly TimeSpan PositionInterval = TimeSpan.FromMilliseconds(300);
+ private static readonly TimeSpan ItemInterval = TimeSpan.FromMilliseconds(5000);
+ private static readonly TimeSpan VitalHeartbeat = TimeSpan.FromSeconds(3);
+ private static readonly TimeSpan PositionHeartbeat = TimeSpan.FromSeconds(10);
+ private static readonly TimeSpan AttemptDedup = TimeSpan.FromMilliseconds(1200);
+ private static readonly TimeSpan SuccessDedup = TimeSpan.FromSeconds(2);
+
+ /// The original's test for "a debuff other casters should know about": a name token.
+ internal static readonly string[] DebuffNameTokens =
+ [
+ "Vulnerability", "Imperil", "Succumb",
+ "Weakness", "Feeblemind", "Dispirit", "Impotence",
+ "Slowness", "Clumsiness", "Frailty", "Bafflement",
+ "Tranquility",
+ "Fire Vulnerability", "Cold Vulnerability",
+ "Acid Vulnerability", "Lightning Vulnerability",
+ "Blade Vulnerability", "Pierce Vulnerability", "Bludgeon Vulnerability",
+ "Defenselessness", "Open Mind",
+ ];
+
+ /// Item enchantment, the skill the original fell back to for a spell of no known school.
+ private const uint ItemEnchantmentSkill = 32u;
+
+ private readonly MmContext _context;
+ private readonly PeerTable _peers = new();
+ private IDisposable? _timer;
+
+ private int _lastHealth = -1, _lastMaxHealth = -1;
+ private int _lastStamina = -1, _lastMaxStamina = -1;
+ private int _lastMana = -1, _lastMaxMana = -1;
+ private double _lastEW, _lastNS, _lastZ, _lastHeading;
+ private DateTime _lastVitalSendUtc = DateTime.MinValue;
+ private DateTime _lastPositionSendUtc = DateTime.MinValue;
+ private DateTime _lastItemSendUtc = DateTime.MinValue;
+
+ private long _ownCastSequence;
+ private long _lastCompletionRevision;
+ private readonly Dictionary<(uint Spell, uint Target), DateTime> _sentAttempts = [];
+ private readonly Dictionary<(uint Spell, uint Target), DateTime> _sentSuccesses = [];
+
+ public VitalSharing(MmContext context)
+ {
+ _context = context;
+ context.Commands.Register("vitalsharing", HandleCommand,
+ "Enable/disable vital sharing with other MosswartOverlord clients");
+ }
+
+ public bool IsActive { get; private set; }
+
+ /// The peers heard from, for the overlay.
+ public PeerTable Peers => _peers;
+
+ /// Toggles the overlay; set by the overlay feature.
+ public Action? OverlayToggle { get; set; }
+
+ public void OnLogin()
+ {
+ if (_context.Settings.Current.VitalSharingEnabled)
+ {
+ Start();
+ _context.Chat.Write("[VitalShare] Enabled at login");
+ }
+ }
+
+ public void OnLogoff() => Stop();
+
+ public void Dispose() => Stop();
+
+ public void Start()
+ {
+ if (IsActive)
+ return;
+ IsActive = true;
+ ResetOutgoingState();
+ _timer = _context.Scheduler.Every(TickInterval, OnTick);
+ _context.Send(new
+ {
+ type = "share_subscribe",
+ timestamp = Wire.Timestamp(_context.Clock),
+ character_name = _context.CharacterName,
+ player_id = PlayerId,
+ tags = Tags,
+ });
+ _context.Verbose("[VitalShare] Started vital sharing tracker");
+ }
+
+ public void Stop()
+ {
+ if (!IsActive)
+ return;
+ IsActive = false;
+ _timer?.Dispose();
+ _timer = null;
+ _context.Send(new
+ {
+ type = "share_unsubscribe",
+ timestamp = Wire.Timestamp(_context.Clock),
+ character_name = _context.CharacterName,
+ });
+ _peers.Clear();
+ _context.Verbose("[VitalShare] Stopped vital sharing tracker");
+ }
+
+ ///
+ /// After every reconnect the original announced itself again, because the
+ /// backend forgets subscribers on disconnect. This re-announcement carried
+ /// no player id, unlike the first; the backend accepts both.
+ ///
+ public void OnBackendConnected()
+ {
+ if (!_context.Settings.Current.VitalSharingEnabled || !_context.IsLoggedIn)
+ return;
+ _context.Send(new
+ {
+ type = "share_subscribe",
+ timestamp = Wire.Timestamp(_context.Clock),
+ character_name = _context.CharacterName,
+ tags = Tags,
+ });
+ _context.Verbose("[WebSocket] Vital sharing re-subscribed");
+ }
+
+ // ── Outgoing ──
+
+ private int PlayerId => unchecked((int)_context.Automation.Character.ObjectId);
+
+ private string[] Tags
+ {
+ get
+ {
+ string tag = _context.Settings.Current.CharTag;
+ return string.IsNullOrWhiteSpace(tag) ? [] : [tag];
+ }
+ }
+
+ internal void OnTick()
+ {
+ if (!IsActive)
+ return;
+ try
+ {
+ TrySendVitalUpdate(force: false);
+ DateTime now = _context.Clock.UtcNow;
+ if (now - _lastPositionSendUtc >= PositionInterval)
+ TrySendPositionUpdate(force: false);
+ if (now - _lastItemSendUtc >= ItemInterval)
+ TrySendItemUpdate();
+ RelayOwnCasts();
+ RelayOwnCompletion();
+ }
+ catch (Exception ex)
+ {
+ _context.Verbose($"[VitalShare] OnTick error: {ex.Message}");
+ }
+ }
+
+ private void TrySendVitalUpdate(bool force)
+ {
+ var character = _context.Automation.Character;
+ int curH = (int)character.CurrentHealth, maxH = (int)character.MaxHealth;
+ int curS = (int)character.CurrentStamina, maxS = (int)character.MaxStamina;
+ int curM = (int)character.CurrentMana, maxM = (int)character.MaxMana;
+
+ bool changed = curH != _lastHealth || maxH != _lastMaxHealth
+ || curS != _lastStamina || maxS != _lastMaxStamina
+ || curM != _lastMana || maxM != _lastMaxMana;
+ bool heartbeat = (_context.Clock.UtcNow - _lastVitalSendUtc) > VitalHeartbeat;
+ if (!force && !changed && !heartbeat)
+ return;
+
+ _lastHealth = curH; _lastMaxHealth = maxH;
+ _lastStamina = curS; _lastMaxStamina = maxS;
+ _lastMana = curM; _lastMaxMana = maxM;
+ _lastVitalSendUtc = _context.Clock.UtcNow;
+
+ _context.Send(new
+ {
+ type = "share_vital_update",
+ timestamp = Wire.Timestamp(_context.Clock),
+ character_name = _context.CharacterName,
+ player_id = PlayerId,
+ tags = Tags,
+ current_health = curH,
+ max_health = maxH,
+ current_stamina = curS,
+ max_stamina = maxS,
+ current_mana = curM,
+ max_mana = maxM,
+ });
+ }
+
+ private void TrySendPositionUpdate(bool force)
+ {
+ PluginNavigationPosition position = _context.Automation.Navigation.Snapshot.Position;
+ MmCoordinates me = MmCoordinates.From(position);
+ double heading = position.HeadingDegrees;
+
+ 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 = (_context.Clock.UtcNow - _lastPositionSendUtc) > PositionHeartbeat;
+ if (!force && !changed && !heartbeat)
+ return;
+
+ _lastEW = me.EW; _lastNS = me.NS; _lastZ = me.Z; _lastHeading = heading;
+ _lastPositionSendUtc = _context.Clock.UtcNow;
+
+ _context.Send(new
+ {
+ type = "share_position_update",
+ timestamp = Wire.Timestamp(_context.Clock),
+ character_name = _context.CharacterName,
+ player_id = PlayerId,
+ tags = Tags,
+ ew = Math.Round(me.EW, 7),
+ ns = Math.Round(me.NS, 7),
+ z = Math.Round(me.Z, 2),
+ heading = Math.Round(heading, 1),
+ });
+ }
+
+ /// The original's placeholder heartbeat: who is alive, no item data.
+ private void TrySendItemUpdate()
+ {
+ _lastItemSendUtc = _context.Clock.UtcNow;
+ _context.Send(new
+ {
+ type = "share_item_update",
+ timestamp = Wire.Timestamp(_context.Clock),
+ character_name = _context.CharacterName,
+ player_id = PlayerId,
+ tags = Tags,
+ });
+ }
+
+ /// The casts the bot announced to this computer's peers, carried to the backend.
+ private void RelayOwnCasts()
+ {
+ INetworkAutomation network = _context.Automation.Network;
+ if (!network.IsAvailable)
+ return;
+ foreach (PluginPeerCast cast in network.CaptureOwnCasts(_ownCastSequence))
+ {
+ _ownCastSequence = Math.Max(_ownCastSequence, cast.Sequence);
+ if (!IsDebuffSpell(cast.SpellId, out PluginSpellInfo spell))
+ continue;
+ if (cast.Landed)
+ SendCastSuccess(spell, cast.TargetObjectId);
+ else
+ SendCastAttempt(spell, cast.TargetObjectId, cast.EffectiveSkill);
+ }
+ }
+
+ ///
+ /// The player's own completed casts, which catches a debuff cast by hand
+ /// that the bot never announced.
+ ///
+ private void RelayOwnCompletion()
+ {
+ // Start() takes the revision current then, so a completion from before
+ // sharing started is never reported.
+ PluginCastCompletion completion = _context.Automation.Magic.LastCompletion;
+ if (completion.Revision == 0 || completion.Revision == _lastCompletionRevision)
+ return;
+ _lastCompletionRevision = completion.Revision;
+ if (!completion.IsSuccess || completion.TargetObjectId == 0)
+ return;
+ if (!IsDebuffSpell(completion.SpellId, out PluginSpellInfo spell))
+ return;
+ SendCastSuccess(spell, completion.TargetObjectId);
+ }
+
+ private void SendCastAttempt(in PluginSpellInfo spell, uint target, int announcedSkill)
+ {
+ DateTime now = _context.Clock.UtcNow;
+ var key = (spell.SpellId, target);
+ if (_sentAttempts.TryGetValue(key, out DateTime last) && now - last < AttemptDedup)
+ return;
+ _sentAttempts[key] = now;
+ Prune(_sentAttempts, now, AttemptDedup);
+
+ double skill = announcedSkill > 0 ? announcedSkill : EffectiveSkillFor(spell);
+ _context.Send(new
+ {
+ type = "share_cast_attempt",
+ timestamp = Wire.Timestamp(_context.Clock),
+ character_name = _context.CharacterName,
+ player_id = PlayerId,
+ tags = Tags,
+ spell_id = unchecked((int)spell.SpellId),
+ target_id = unchecked((int)target),
+ skill,
+ });
+ _context.Verbose($"[VitalShare] SEND share_cast_attempt spell={spell.SpellId} target=0x{target:X8} skill={(int)skill}");
+ }
+
+ private void SendCastSuccess(in PluginSpellInfo spell, uint target)
+ {
+ if (target == 0)
+ return;
+ DateTime now = _context.Clock.UtcNow;
+ var key = (spell.SpellId, target);
+ // The bot's announcement and the cast's own completion describe the
+ // same landing; send it once.
+ if (_sentSuccesses.TryGetValue(key, out DateTime last) && now - last < SuccessDedup)
+ return;
+ _sentSuccesses[key] = now;
+ Prune(_sentSuccesses, now, SuccessDedup);
+
+ int durationMs = (int)(spell.DurationSeconds * 1000);
+ _context.Send(new
+ {
+ type = "share_cast_success",
+ timestamp = Wire.Timestamp(_context.Clock),
+ character_name = _context.CharacterName,
+ player_id = PlayerId,
+ tags = Tags,
+ spell_id = unchecked((int)spell.SpellId),
+ target_id = unchecked((int)target),
+ duration_ms = durationMs,
+ });
+ _context.Verbose($"[VitalShare] SEND share_cast_success spell={spell.SpellId} target=0x{target:X8} duration={durationMs}ms");
+ }
+
+ private double EffectiveSkillFor(in PluginSpellInfo spell)
+ {
+ uint skillId = spell.School != 0u ? spell.School : ItemEnchantmentSkill;
+ return _context.Automation.Character.TryGetSkill(skillId, out PluginSkillInfo skill)
+ ? skill.Current
+ : 0d;
+ }
+
+ private bool IsDebuffSpell(uint spellId, out PluginSpellInfo spell)
+ {
+ if (!_context.Automation.Spells.TryGet(spellId, out spell))
+ return false;
+ string name = spell.Name ?? string.Empty;
+ foreach (string token in DebuffNameTokens)
+ {
+ if (name.Contains(token, StringComparison.OrdinalIgnoreCase))
+ return true;
+ }
+ return false;
+ }
+
+ private static void Prune(Dictionary<(uint, uint), DateTime> sent, DateTime now, TimeSpan keep)
+ {
+ if (sent.Count < 64)
+ return;
+ foreach (var key in sent.Where(pair => now - pair.Value >= keep).Select(pair => pair.Key).ToList())
+ sent.Remove(key);
+ }
+
+ private void ResetOutgoingState()
+ {
+ _lastHealth = _lastMaxHealth = _lastStamina = _lastMaxStamina = _lastMana = _lastMaxMana = -1;
+ _lastEW = _lastNS = _lastZ = _lastHeading = 0;
+ _lastVitalSendUtc = _lastPositionSendUtc = _lastItemSendUtc = DateTime.MinValue;
+ _ownCastSequence = 0;
+ _lastCompletionRevision = _context.Automation.Magic.LastCompletion.Revision;
+ _sentAttempts.Clear();
+ _sentSuccesses.Clear();
+ }
+
+ // ── Incoming ──
+
+ public void HandleShareFrame(string type, JObject frame)
+ {
+ if (!IsActive)
+ return;
+ switch (type)
+ {
+ case "share_vital_update":
+ OnVitalUpdate(frame);
+ break;
+ case "share_position_update":
+ OnPositionUpdate(frame);
+ break;
+ case "share_cast_attempt":
+ OnCast(frame, landed: false);
+ break;
+ case "share_cast_success":
+ OnCast(frame, landed: true);
+ break;
+ }
+ }
+
+ private bool IsSelf(string name) =>
+ name.Equals(_context.CharacterName, StringComparison.OrdinalIgnoreCase);
+
+ private void OnVitalUpdate(JObject frame)
+ {
+ string from = (string?)frame["character_name"] ?? "";
+ if (IsSelf(from))
+ return;
+ int playerId = (int?)frame["player_id"] ?? 0;
+ int maxH = (int?)frame["max_health"] ?? 0;
+ if (playerId == 0 || maxH == 0)
+ return;
+
+ Peer peer = _peers.Upsert(from, _context.Clock.UtcNow);
+ peer.PlayerId = unchecked((uint)playerId);
+ peer.CurrentHealth = (int?)frame["current_health"] ?? 0;
+ peer.MaxHealth = maxH;
+ peer.CurrentStamina = (int?)frame["current_stamina"] ?? 0;
+ peer.MaxStamina = (int?)frame["max_stamina"] ?? 0;
+ peer.CurrentMana = (int?)frame["current_mana"] ?? 0;
+ peer.MaxMana = (int?)frame["max_mana"] ?? 0;
+ peer.Tags = TagsOf(frame);
+ Import(peer);
+ }
+
+ private void OnPositionUpdate(JObject frame)
+ {
+ string from = (string?)frame["character_name"] ?? "";
+ if (from.Length == 0 || IsSelf(from))
+ return;
+ Peer peer = _peers.Upsert(from, _context.Clock.UtcNow);
+ if ((int?)frame["player_id"] is int id && id != 0)
+ peer.PlayerId = unchecked((uint)id);
+ peer.EW = (double?)frame["ew"] ?? 0;
+ peer.NS = (double?)frame["ns"] ?? 0;
+ peer.Z = (double?)frame["z"] ?? 0;
+ peer.Heading = (double?)frame["heading"] ?? 0;
+ peer.HasPosition = true;
+ if (frame["tags"] is JArray)
+ peer.Tags = TagsOf(frame);
+ Import(peer);
+ }
+
+ private void OnCast(JObject frame, bool landed)
+ {
+ string from = (string?)frame["character_name"] ?? "";
+ if (IsSelf(from))
+ return;
+ int spellId = (int?)frame["spell_id"] ?? 0;
+ int target = (int?)frame["target_id"] ?? 0;
+ if (spellId == 0 || target == 0)
+ return;
+
+ uint caster = (int?)frame["player_id"] is int id && id != 0
+ ? unchecked((uint)id)
+ : _peers.TryGet(from, out Peer? known) ? known!.PlayerId : 0u;
+ if (caster == 0)
+ return;
+
+ double skill = (double?)frame["skill"] ?? 0;
+ double seconds = landed ? ((int?)frame["duration_ms"] ?? 0) / 1000d : 0d;
+ bool accepted = _context.Automation.Network.ImportRemoteCast(
+ caster,
+ unchecked((uint)target),
+ unchecked((uint)spellId),
+ (int)skill,
+ seconds,
+ landed);
+ _context.Verbose(
+ $"[VitalShare→peers] {(landed ? "cast" : "attempt")} from {from}: spell={spellId} target=0x{unchecked((uint)target):X8}{(accepted ? "" : " (not taken)")}");
+ }
+
+ private void Import(Peer peer)
+ {
+ if (peer.PlayerId == 0)
+ return;
+ PluginNavigationPosition position = peer.HasPosition
+ ? PositionOf(peer)
+ : default;
+ var client = new PluginNetworkClient(
+ ClientId: 0u,
+ PlayerId: peer.PlayerId,
+ Name: peer.CharacterName,
+ WorldName: _context.Automation.Character.WorldName,
+ Position: position,
+ Tags: peer.Tags,
+ CurrentHealth: (uint)Math.Max(0, peer.CurrentHealth),
+ CurrentMana: (uint)Math.Max(0, peer.CurrentMana),
+ CurrentStamina: (uint)Math.Max(0, peer.CurrentStamina),
+ MaxHealth: (uint)Math.Max(0, peer.MaxHealth),
+ MaxMana: (uint)Math.Max(0, peer.MaxMana),
+ MaxStamina: (uint)Math.Max(0, peer.MaxStamina),
+ Heading: (float)peer.Heading);
+ bool accepted = _context.Automation.Network.ImportRemoteClient(client);
+ if (!accepted)
+ _context.Verbose($"[VitalShare→peers] {peer.CharacterName} (0x{peer.PlayerId:X8}) not taken");
+ }
+
+ /// A peer's map coordinates back as a host position; the cell is the outdoor cell they fall in.
+ internal static PluginNavigationPosition PositionOf(Peer peer)
+ {
+ uint cell = LandcellFromCoordinates(peer.EW, peer.NS);
+ return new PluginNavigationPosition(cell, peer.EW, peer.NS, peer.Z / 240d, (float)peer.Heading, true);
+ }
+
+ /// The original's map-coordinate to outdoor landcell conversion.
+ internal static uint LandcellFromCoordinates(double ew, double ns)
+ {
+ ns = (ns - 0.5) * 10.0;
+ ew = (ew - 0.5) * 10.0;
+ uint basex = (uint)(ew + 0x400);
+ uint basey = (uint)(ns + 0x400);
+ byte bx = (byte)(basex >> 3);
+ byte by = (byte)(basey >> 3);
+ byte cx = (byte)(basex & 7);
+ byte cy = (byte)(basey & 7);
+ int block = (bx << 8) | by;
+ int cellIndex = (cx << 3) | cy;
+ return (uint)((block << 16) | (cellIndex + 1));
+ }
+
+ private static IReadOnlyList TagsOf(JObject frame) =>
+ frame["tags"] is JArray tags
+ ? [.. tags.Select(tag => (string?)tag).Where(tag => !string.IsNullOrEmpty(tag)).Select(tag => tag!)]
+ : [];
+
+ // ── Command ──
+
+ private void HandleCommand(string[] args)
+ {
+ string sub = args.Length > 1 ? args[1].ToLowerInvariant() : string.Empty;
+ switch (sub)
+ {
+ case "on":
+ if (!_context.Settings.Current.WebSocketEnabled)
+ {
+ _context.Chat.Write("[VitalShare] WebSocket must be enabled first. Run /mm ws enable");
+ return;
+ }
+ SetEnabled(true);
+ break;
+ case "off":
+ SetEnabled(false);
+ break;
+ case "status":
+ _context.Chat.Write(
+ $"[VitalShare] enabled={_context.Settings.Current.VitalSharingEnabled} active={IsActive} tag='{_context.Settings.Current.CharTag ?? ""}'");
+ break;
+ case "overlay":
+ if (OverlayToggle is { } toggle)
+ toggle();
+ else
+ _context.Chat.Write("[VitalShare] The overlay needs the graphical client.");
+ break;
+ default:
+ _context.Chat.Write("Usage: /mm vitalsharing ");
+ break;
+ }
+ }
+
+ private void SetEnabled(bool enabled)
+ {
+ _context.Settings.Current.VitalSharingEnabled = enabled;
+ _context.Settings.Save();
+ if (enabled)
+ {
+ if (_context.IsLoggedIn)
+ Start();
+ _context.Chat.Write("[VitalShare] ENABLED");
+ }
+ else
+ {
+ Stop();
+ _context.Chat.Write("[VitalShare] DISABLED");
+ }
+ }
+}
diff --git a/tests/OpenAC.MosswartMassacre.Tests/Fakes/FakeAutomation.cs b/tests/OpenAC.MosswartMassacre.Tests/Fakes/FakeAutomation.cs
index c23551a..be81fc6 100644
--- a/tests/OpenAC.MosswartMassacre.Tests/Fakes/FakeAutomation.cs
+++ b/tests/OpenAC.MosswartMassacre.Tests/Fakes/FakeAutomation.cs
@@ -40,6 +40,8 @@ public sealed class FakeAutomation : IAutomationSurface
public FakeLogin Login { get; } = new();
+ public FakeNetwork Network { get; } = new();
+
ICharacterInfo IAutomationSurface.Character => Character;
ISpellCatalog IAutomationSurface.Spells => Spells;
IMagicCommands IAutomationSurface.Magic => Magic;
@@ -55,6 +57,7 @@ public sealed class FakeAutomation : IAutomationSurface
ITradeAutomation IAutomationSurface.Trade => Trade;
IVendorAutomation IAutomationSurface.Vendor => Vendor;
ILoginAutomation IAutomationSurface.Login => Login;
+ INetworkAutomation IAutomationSurface.Network => Network;
}
/// A settable : a test drives and inspects .
@@ -500,6 +503,8 @@ public sealed class FakeMagic : IMagicCommands
{
public bool IsCasting { get; set; }
+ public PluginCastCompletion LastCompletion { get; set; }
+
public List<(uint SpellId, uint TargetObjectId)> Casts { get; } = [];
/// What / answer -- a test sets this to simulate a refusal that isn't a plain Cast()==false.
diff --git a/tests/OpenAC.MosswartMassacre.Tests/Fakes/FakeNetwork.cs b/tests/OpenAC.MosswartMassacre.Tests/Fakes/FakeNetwork.cs
new file mode 100644
index 0000000..e910dee
--- /dev/null
+++ b/tests/OpenAC.MosswartMassacre.Tests/Fakes/FakeNetwork.cs
@@ -0,0 +1,45 @@
+using AcDream.Plugin.Abstractions;
+
+namespace OpenAC.MosswartMassacre.Tests.Fakes;
+
+///
+/// A peer network that records what a plugin imports and hands back the casts
+/// this client announced.
+///
+public sealed class FakeNetwork : INetworkAutomation
+{
+ public bool IsAvailable { get; set; } = true;
+
+ public List ImportedClients { get; } = [];
+
+ public List<(uint Caster, uint Target, uint Spell, int Skill, double Seconds, bool Landed)> ImportedCasts { get; } = [];
+
+ /// What this client announced, as reports it.
+ public List OwnCasts { get; } = [];
+
+ /// Whether an import is accepted.
+ public bool AcceptImports { get; set; } = true;
+
+ public IReadOnlyList CaptureClients() => ImportedClients;
+
+ public IReadOnlyList CaptureOwnCasts(long afterSequence) =>
+ [.. OwnCasts.Where(cast => cast.Sequence > afterSequence)];
+
+ public bool ImportRemoteClient(PluginNetworkClient client)
+ {
+ ImportedClients.Add(client);
+ return AcceptImports;
+ }
+
+ public bool ImportRemoteCast(
+ uint casterObjectId,
+ uint targetObjectId,
+ uint spellId,
+ int effectiveSkill,
+ double secondsRemaining,
+ bool landed)
+ {
+ ImportedCasts.Add((casterObjectId, targetObjectId, spellId, effectiveSkill, secondsRemaining, landed));
+ return AcceptImports;
+ }
+}
diff --git a/tests/OpenAC.MosswartMassacre.Tests/Sharing/VitalSharingTests.cs b/tests/OpenAC.MosswartMassacre.Tests/Sharing/VitalSharingTests.cs
new file mode 100644
index 0000000..2785e4d
--- /dev/null
+++ b/tests/OpenAC.MosswartMassacre.Tests/Sharing/VitalSharingTests.cs
@@ -0,0 +1,280 @@
+using System.Globalization;
+using AcDream.Plugin.Abstractions;
+using Newtonsoft.Json.Linq;
+using OpenAC.MosswartMassacre.Sharing;
+using OpenAC.MosswartMassacre.Tests.Streams;
+
+namespace OpenAC.MosswartMassacre.Tests.Sharing;
+
+///
+/// The share_* frames exactly as the original's VitalSharingTracker wrote
+/// them, and the way frames from other computers reach the host's peer
+/// network.
+///
+public sealed class VitalSharingTests
+{
+ private const string Stamp = "2026-09-25T08:30:15.1230000Z";
+ private const uint Me = 0x50000001;
+
+ private static readonly PluginSpellInfo Vuln = new(
+ 2074u, "Fire Vulnerability Other VI", 300u, 6, 300, 60, 240f, 31u, "", false, false);
+
+ private static readonly PluginSpellInfo Heal = new(
+ 4u, "Heal Other VI", 400u, 6, 300, 60, 0f, 33u, "", false, true);
+
+ private static PluginHarness Sharing(Action? arrange = null)
+ {
+ var harness = new PluginHarness("Mossy");
+ var c = harness.Host.Automation.Character;
+ c.CurrentHealth = 150; c.MaxHealth = 200;
+ c.CurrentStamina = 300; c.MaxStamina = 300;
+ c.CurrentMana = 50; c.MaxMana = 100;
+ c.Skills.Add(new PluginSkillInfo(31u, "Creature Enchantment", PluginSkillTraining.Specialized, 380u));
+ harness.Host.Automation.Spells.All = [Vuln, Heal];
+ harness.Host.Automation.Navigation.Snapshot = new PluginNavigationSnapshot(
+ true, false, 1u, StreamGoldenTests.Position(), false, false);
+ arrange?.Invoke(harness);
+ harness.Login();
+ harness.Context.Settings.Current.CharTag = "tank";
+ harness.Frames.Clear();
+ return harness;
+ }
+
+ private static VitalSharing Feature(PluginHarness harness) => harness.Feature();
+
+ [Fact]
+ public void Subscribe_on_start_carries_the_player_id_and_tags()
+ {
+ // VitalSharingTracker.cs:144-151.
+ using var harness = Sharing();
+
+ Feature(harness).Start();
+
+ Assert.Equal(
+ "{\"type\":\"share_subscribe\",\"timestamp\":\"" + Stamp + "\",\"character_name\":\"Mossy\","
+ + "\"player_id\":1342177281,\"tags\":[\"tank\"]}",
+ harness.Frames[0]);
+ }
+
+ [Fact]
+ public void The_reconnect_subscribe_carries_no_player_id()
+ {
+ // WebSocket.cs:121-136: the replay after every reconnect.
+ using var harness = Sharing();
+ harness.Context.Settings.Current.VitalSharingEnabled = true;
+ harness.Context.Settings.Current.CharTag = "";
+
+ harness.SimulateConnected();
+
+ Assert.Contains(
+ "{\"type\":\"share_subscribe\",\"timestamp\":\"" + Stamp + "\",\"character_name\":\"Mossy\",\"tags\":[]}",
+ harness.Frames);
+ }
+
+ [Fact]
+ public void Vital_position_and_item_frames()
+ {
+ // VitalSharingTracker.cs:422-528: vitals on change or 3 s, position
+ // on change or 10 s (ew/ns to 7 places, z to 2, heading to 1), the
+ // item heartbeat every 5 s with no item data.
+ using var harness = Sharing();
+ Feature(harness).Start();
+ harness.Frames.Clear();
+
+ Feature(harness).OnTick();
+
+ string ew = Math.Round((double)33.65f, 7).ToString("R", CultureInfo.InvariantCulture);
+ string ns = Math.Round((double)42.55f, 7).ToString("R", CultureInfo.InvariantCulture);
+ Assert.Equal(
+ [
+ "{\"type\":\"share_vital_update\",\"timestamp\":\"" + Stamp + "\",\"character_name\":\"Mossy\","
+ + "\"player_id\":1342177281,\"tags\":[\"tank\"],\"current_health\":150,\"max_health\":200,"
+ + "\"current_stamina\":300,\"max_stamina\":300,\"current_mana\":50,\"max_mana\":100}",
+ "{\"type\":\"share_position_update\",\"timestamp\":\"" + Stamp + "\",\"character_name\":\"Mossy\","
+ + "\"player_id\":1342177281,\"tags\":[\"tank\"],\"ew\":" + ew + ",\"ns\":" + ns + ",\"z\":42.5,\"heading\":90.0}",
+ "{\"type\":\"share_item_update\",\"timestamp\":\"" + Stamp + "\",\"character_name\":\"Mossy\","
+ + "\"player_id\":1342177281,\"tags\":[\"tank\"]}",
+ ],
+ harness.Frames);
+ }
+
+ [Fact]
+ public void Vitals_resend_only_on_change_or_after_three_seconds()
+ {
+ using var harness = Sharing();
+ Feature(harness).Start();
+ Feature(harness).OnTick();
+ harness.Frames.Clear();
+
+ harness.Clock.Advance(TimeSpan.FromMilliseconds(150));
+ Feature(harness).OnTick();
+ Assert.Empty(harness.FramesOfType("share_vital_update"));
+
+ harness.Host.Automation.Character.CurrentHealth = 149;
+ Feature(harness).OnTick();
+ Assert.Single(harness.FramesOfType("share_vital_update"));
+
+ harness.Frames.Clear();
+ harness.Clock.Advance(TimeSpan.FromSeconds(3.1));
+ Feature(harness).OnTick();
+ Assert.Single(harness.FramesOfType("share_vital_update"));
+ }
+
+ [Fact]
+ public void A_debuff_the_bot_announced_goes_out_as_attempt_then_success()
+ {
+ // VitalSharingTracker.cs:530-636: spell_id/target_id as signed ints,
+ // skill a double, duration_ms = spell duration * 1000.
+ using var harness = Sharing();
+ Feature(harness).Start();
+ harness.Frames.Clear();
+ var network = harness.Host.Automation.Network;
+ network.OwnCasts.Add(new PluginPeerCast(1, 7, Me, 0x80001234, Vuln.SpellId, 380, 0, false));
+ network.OwnCasts.Add(new PluginPeerCast(2, 7, Me, 0x80001234, Vuln.SpellId, 380, 240, true));
+ network.OwnCasts.Add(new PluginPeerCast(3, 7, Me, 0x50000002, Heal.SpellId, 380, 0, false));
+
+ Feature(harness).OnTick();
+
+ JObject[] casts = [.. harness.Frames.Select(JObject.Parse)
+ .Where(f => ((string?)f["type"])!.StartsWith("share_cast", StringComparison.Ordinal))];
+ Assert.Equal(2, casts.Length);
+ Assert.Equal(
+ "{\"type\":\"share_cast_attempt\",\"timestamp\":\"" + Stamp + "\",\"character_name\":\"Mossy\","
+ + "\"player_id\":1342177281,\"tags\":[\"tank\"],\"spell_id\":2074,\"target_id\":-2147478988,\"skill\":380.0}",
+ harness.Frames.Single(f => f.Contains("share_cast_attempt", StringComparison.Ordinal)));
+ Assert.Equal(
+ "{\"type\":\"share_cast_success\",\"timestamp\":\"" + Stamp + "\",\"character_name\":\"Mossy\","
+ + "\"player_id\":1342177281,\"tags\":[\"tank\"],\"spell_id\":2074,\"target_id\":-2147478988,\"duration_ms\":240000}",
+ harness.Frames.Single(f => f.Contains("share_cast_success", StringComparison.Ordinal)));
+
+ // Already relayed: nothing new next time.
+ harness.Frames.Clear();
+ Feature(harness).OnTick();
+ Assert.DoesNotContain(harness.Frames, f => f.Contains("share_cast", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public void A_hand_cast_debuff_is_shared_when_it_lands_and_only_once()
+ {
+ using var harness = Sharing();
+ Feature(harness).Start();
+ harness.Frames.Clear();
+
+ harness.Host.Automation.Magic.LastCompletion = new PluginCastCompletion(5, Vuln.SpellId, 0x80001234, 0);
+ Feature(harness).OnTick();
+ harness.Host.Automation.Network.OwnCasts.Add(new PluginPeerCast(1, 7, Me, 0x80001234, Vuln.SpellId, 380, 240, true));
+ Feature(harness).OnTick();
+
+ Assert.Single(harness.FramesOfType("share_cast_success"));
+ }
+
+ [Fact]
+ public void A_completion_from_before_sharing_started_is_not_reported()
+ {
+ using var harness = Sharing(h =>
+ h.Host.Automation.Magic.LastCompletion = new PluginCastCompletion(5, Vuln.SpellId, 0x80001234, 0));
+
+ Feature(harness).Start();
+ Feature(harness).OnTick();
+
+ Assert.Empty(harness.FramesOfType("share_cast_success"));
+ }
+
+ [Fact]
+ public void Another_computers_vitals_and_position_reach_the_peer_network()
+ {
+ using var harness = Sharing();
+ Feature(harness).Start();
+
+ harness.Receive("{\"type\":\"share_position_update\",\"character_name\":\"Horan\",\"player_id\":1342177282,"
+ + "\"tags\":[\"tank\"],\"ew\":33.66,\"ns\":42.55,\"z\":12.5,\"heading\":45.0}");
+ harness.Receive("{\"type\":\"share_vital_update\",\"character_name\":\"Horan\",\"player_id\":1342177282,"
+ + "\"tags\":[\"tank\"],\"current_health\":20,\"max_health\":200,\"current_stamina\":1,\"max_stamina\":2,"
+ + "\"current_mana\":3,\"max_mana\":4}");
+
+ PluginNetworkClient imported = harness.Host.Automation.Network.ImportedClients[^1];
+ Assert.Equal(0x50000002u, imported.PlayerId);
+ Assert.Equal("Horan", imported.Name);
+ Assert.Equal(20u, imported.CurrentHealth);
+ Assert.Equal(200u, imported.MaxHealth);
+ Assert.Equal(33.66, imported.Position.EastWest);
+ Assert.Equal(12.5 / 240d, imported.Position.Elevation);
+ Assert.Equal(["tank"], imported.Tags);
+ Assert.Equal(
+ VitalSharing.LandcellFromCoordinates(33.66, 42.55),
+ imported.Position.CellId);
+ Assert.Single(Feature(harness).Peers.Current(harness.Clock.UtcNow));
+ }
+
+ [Fact]
+ public void Another_computers_casts_reach_the_peer_network_as_the_caster()
+ {
+ using var harness = Sharing();
+ Feature(harness).Start();
+
+ harness.Receive("{\"type\":\"share_cast_attempt\",\"character_name\":\"Horan\",\"player_id\":1342177282,"
+ + "\"spell_id\":2074,\"target_id\":-2147478988,\"skill\":350.0}");
+ harness.Receive("{\"type\":\"share_cast_success\",\"character_name\":\"Horan\",\"player_id\":1342177282,"
+ + "\"spell_id\":2074,\"target_id\":-2147478988,\"duration_ms\":240000}");
+
+ Assert.Equal(
+ [
+ (0x50000002u, 0x80001234u, 2074u, 350, 0d, false),
+ (0x50000002u, 0x80001234u, 2074u, 0, 240d, true),
+ ],
+ harness.Host.Automation.Network.ImportedCasts);
+ }
+
+ [Fact]
+ public void Own_frames_echoed_back_are_ignored()
+ {
+ using var harness = Sharing();
+ Feature(harness).Start();
+
+ harness.Receive("{\"type\":\"share_vital_update\",\"character_name\":\"mossy\",\"player_id\":1342177281,"
+ + "\"current_health\":1,\"max_health\":2}");
+
+ Assert.Empty(harness.Host.Automation.Network.ImportedClients);
+ }
+
+ [Fact]
+ public void Nothing_is_imported_while_sharing_is_off()
+ {
+ using var harness = Sharing();
+
+ harness.Receive("{\"type\":\"share_vital_update\",\"character_name\":\"Horan\",\"player_id\":1342177282,"
+ + "\"current_health\":1,\"max_health\":2}");
+
+ Assert.Empty(harness.Host.Automation.Network.ImportedClients);
+ }
+
+ [Fact]
+ public void Stop_says_goodbye()
+ {
+ using var harness = Sharing();
+ Feature(harness).Start();
+ harness.Frames.Clear();
+
+ Feature(harness).Stop();
+
+ Assert.Equal(
+ "{\"type\":\"share_unsubscribe\",\"timestamp\":\"" + Stamp + "\",\"character_name\":\"Mossy\"}",
+ Assert.Single(harness.Frames));
+ }
+
+ [Fact]
+ public void The_command_needs_streaming_first_and_toggles_the_setting()
+ {
+ using var harness = new PluginHarness("Mossy", webSocketEnabled: false);
+ harness.Login();
+
+ harness.Context.Commands.Dispatch("vitalsharing on");
+ Assert.Contains("[Mosswart Massacre] [VitalShare] WebSocket must be enabled first. Run /mm ws enable", harness.ChatLines);
+ Assert.False(harness.Context.Settings.Current.VitalSharingEnabled);
+
+ harness.Context.Settings.Current.WebSocketEnabled = true;
+ harness.Context.Commands.Dispatch("vitalsharing on");
+ Assert.True(harness.Context.Settings.Current.VitalSharingEnabled);
+ Assert.True(harness.Feature().IsActive);
+ }
+}