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); + } +}