From 78813cf861d6e193c02a0027815b09b39a759202 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 25 Sep 2026 12:24:00 +0200 Subject: [PATCH] Vital sharing across computers through the backend Outgoing, the original's share_* frames byte for byte: subscribe with the player id on start and without it after every reconnect, vitals on change or every 3 s, position on change or every 10 s, the item heartbeat every 5 s, unsubscribe on stop. Debuff casts come from the host's peer network: the attempts and landings the bot announces, plus the player's own completed casts, so a hand-cast debuff is shared once it lands (a plugin cannot see outgoing packets, so hand-cast attempts are not). Ids stay signed ints, skill a double, duration_ms the spell's duration. Incoming, another computer's characters and casts go into the host's peer network (the new relay imports), where the bot reads them exactly as it reads characters on this computer: vitals and position for heals, landed debuffs for target choice. Peers not heard from for 15 s drop out; the original kept them forever. Logoff now runs features in reverse so the unsubscribe leaves before the socket closes, and stopping the socket flushes what was queued. Builds against a preview of the plugin API with the peer relay (0.1.19-mmpreview.1, packed from the relay commit); switch to the released package when it exists. Co-Authored-By: Claude Opus 5.5 (1M context) --- Directory.Build.props | 2 +- .../Backend/BackendClient.cs | 25 +- src/OpenAC.MosswartMassacre/FeatureCatalog.cs | 6 + .../MosswartMassacrePlugin.cs | 5 +- .../Sharing/PeerTable.cs | 81 +++ .../Sharing/VitalSharing.cs | 597 ++++++++++++++++++ .../Fakes/FakeAutomation.cs | 5 + .../Fakes/FakeNetwork.cs | 45 ++ .../Sharing/VitalSharingTests.cs | 280 ++++++++ 9 files changed, 1042 insertions(+), 4 deletions(-) create mode 100644 src/OpenAC.MosswartMassacre/Sharing/PeerTable.cs create mode 100644 src/OpenAC.MosswartMassacre/Sharing/VitalSharing.cs create mode 100644 tests/OpenAC.MosswartMassacre.Tests/Fakes/FakeNetwork.cs create mode 100644 tests/OpenAC.MosswartMassacre.Tests/Sharing/VitalSharingTests.cs 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); + } +}