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) <noreply@anthropic.com>
This commit is contained in:
parent
5799ea956e
commit
78813cf861
9 changed files with 1042 additions and 4 deletions
|
|
@ -15,7 +15,7 @@
|
||||||
<!-- The plugin contract, compile-only, from the .nupkg in ./packages-local
|
<!-- The plugin contract, compile-only, from the .nupkg in ./packages-local
|
||||||
(downloaded from the OpenAC release of the same version). The host
|
(downloaded from the OpenAC release of the same version). The host
|
||||||
supplies the contract assembly at runtime. -->
|
supplies the contract assembly at runtime. -->
|
||||||
<PluginApiPackageVersion>0.1.18</PluginApiPackageVersion>
|
<PluginApiPackageVersion>0.1.19-mmpreview.1</PluginApiPackageVersion>
|
||||||
<RestorePackagesWithLockFile>false</RestorePackagesWithLockFile>
|
<RestorePackagesWithLockFile>false</RestorePackagesWithLockFile>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ internal sealed class BackendClient : IDisposable
|
||||||
public const string SecretHeader = "X-Plugin-Secret";
|
public const string SecretHeader = "X-Plugin-Secret";
|
||||||
|
|
||||||
private static readonly TimeSpan ReconnectDelay = TimeSpan.FromSeconds(2);
|
private static readonly TimeSpan ReconnectDelay = TimeSpan.FromSeconds(2);
|
||||||
|
private static readonly TimeSpan FlushTimeout = TimeSpan.FromMilliseconds(500);
|
||||||
|
|
||||||
private readonly IPluginLogger _log;
|
private readonly IPluginLogger _log;
|
||||||
private readonly ConcurrentQueue<string> _inbound = new();
|
private readonly ConcurrentQueue<string> _inbound = new();
|
||||||
|
|
@ -36,6 +37,7 @@ internal sealed class BackendClient : IDisposable
|
||||||
private CancellationTokenSource? _cts;
|
private CancellationTokenSource? _cts;
|
||||||
private Task? _loop;
|
private Task? _loop;
|
||||||
private Channel<string>? _outbound;
|
private Channel<string>? _outbound;
|
||||||
|
private Task? _sendLoop;
|
||||||
|
|
||||||
public BackendClient(IPluginLogger log)
|
public BackendClient(IPluginLogger log)
|
||||||
{
|
{
|
||||||
|
|
@ -79,18 +81,35 @@ internal sealed class BackendClient : IDisposable
|
||||||
{
|
{
|
||||||
Task? loop;
|
Task? loop;
|
||||||
CancellationTokenSource? cts;
|
CancellationTokenSource? cts;
|
||||||
|
Channel<string>? outbound;
|
||||||
|
Task? sendLoop;
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
{
|
{
|
||||||
if (_cts is null)
|
if (_cts is null)
|
||||||
return;
|
return;
|
||||||
cts = _cts;
|
cts = _cts;
|
||||||
_cts = null;
|
_cts = null;
|
||||||
cts.Cancel();
|
outbound = _outbound;
|
||||||
_outbound?.Writer.TryComplete();
|
|
||||||
_outbound = null;
|
_outbound = null;
|
||||||
|
sendLoop = _sendLoop;
|
||||||
loop = _loop;
|
loop = _loop;
|
||||||
_loop = null;
|
_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
|
try
|
||||||
{
|
{
|
||||||
loop?.Wait(TimeSpan.FromSeconds(2));
|
loop?.Wait(TimeSpan.FromSeconds(2));
|
||||||
|
|
@ -159,6 +178,8 @@ internal sealed class BackendClient : IDisposable
|
||||||
|
|
||||||
Task receive = ReceiveLoopAsync(socket, token);
|
Task receive = ReceiveLoopAsync(socket, token);
|
||||||
Task send = SendLoopAsync(socket, outbound.Reader, token);
|
Task send = SendLoopAsync(socket, outbound.Reader, token);
|
||||||
|
lock (_gate)
|
||||||
|
_sendLoop = send;
|
||||||
await Task.WhenAny(receive, send).ConfigureAwait(false);
|
await Task.WhenAny(receive, send).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (token.IsCancellationRequested)
|
catch (OperationCanceledException) when (token.IsCancellationRequested)
|
||||||
|
|
|
||||||
|
|
@ -29,5 +29,11 @@ internal static class FeatureCatalog
|
||||||
features.Add(new BotControl(context));
|
features.Add(new BotControl(context));
|
||||||
features.Add(new Views.MainWindow(context, stats));
|
features.Add(new Views.MainWindow(context, stats));
|
||||||
session.AddConnectionListener(telemetry);
|
session.AddConnectionListener(telemetry);
|
||||||
|
|
||||||
|
// Cross-computer vital and debuff sharing.
|
||||||
|
var sharing = new Sharing.VitalSharing(context);
|
||||||
|
features.Add(sharing);
|
||||||
|
session.AddConnectionListener(sharing);
|
||||||
|
session.AddShareHandler(sharing);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -131,8 +131,11 @@ public sealed class MosswartMassacrePlugin : IAcDreamPlugin
|
||||||
{
|
{
|
||||||
if (_context is null || _host is null || !_context.IsLoggedIn)
|
if (_context is null || _host is null || !_context.IsLoggedIn)
|
||||||
return;
|
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
|
try
|
||||||
{
|
{
|
||||||
feature.OnLogoff();
|
feature.OnLogoff();
|
||||||
|
|
|
||||||
81
src/OpenAC.MosswartMassacre/Sharing/PeerTable.cs
Normal file
81
src/OpenAC.MosswartMassacre/Sharing/PeerTable.cs
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
namespace OpenAC.MosswartMassacre.Sharing;
|
||||||
|
|
||||||
|
/// <summary>One character heard from through the backend.</summary>
|
||||||
|
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; }
|
||||||
|
|
||||||
|
/// <summary>Height in metres.</summary>
|
||||||
|
public double Z { get; set; }
|
||||||
|
|
||||||
|
public double Heading { get; set; }
|
||||||
|
|
||||||
|
public bool HasPosition { get; set; }
|
||||||
|
|
||||||
|
public IReadOnlyList<string> Tags { get; set; } = [];
|
||||||
|
|
||||||
|
public DateTime LastUpdateUtc { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class PeerTable
|
||||||
|
{
|
||||||
|
public static readonly TimeSpan Staleness = TimeSpan.FromSeconds(15);
|
||||||
|
|
||||||
|
private readonly Dictionary<string, Peer> _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);
|
||||||
|
|
||||||
|
/// <summary>The peers heard from within the last fifteen seconds, by name.</summary>
|
||||||
|
public IReadOnlyList<Peer> 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();
|
||||||
|
}
|
||||||
597
src/OpenAC.MosswartMassacre/Sharing/VitalSharing.cs
Normal file
597
src/OpenAC.MosswartMassacre/Sharing/VitalSharing.cs
Normal file
|
|
@ -0,0 +1,597 @@
|
||||||
|
using AcDream.Plugin.Abstractions;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using OpenAC.MosswartMassacre.Backend;
|
||||||
|
using OpenAC.MosswartMassacre.Infrastructure;
|
||||||
|
|
||||||
|
namespace OpenAC.MosswartMassacre.Sharing;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cross-computer vital and debuff sharing through the backend. Outgoing, it
|
||||||
|
/// sends this character's vitals, position and debuff casts as the original's
|
||||||
|
/// <c>share_*</c> 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.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// 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.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// 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.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
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);
|
||||||
|
|
||||||
|
/// <summary>The original's test for "a debuff other casters should know about": a name token.</summary>
|
||||||
|
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",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>Item enchantment, the skill the original fell back to for a spell of no known school.</summary>
|
||||||
|
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; }
|
||||||
|
|
||||||
|
/// <summary>The peers heard from, for the overlay.</summary>
|
||||||
|
public PeerTable Peers => _peers;
|
||||||
|
|
||||||
|
/// <summary>Toggles the overlay; set by the overlay feature.</summary>
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The original's placeholder heartbeat: who is alive, no item data.</summary>
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The casts the bot announced to this computer's peers, carried to the backend.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The player's own completed casts, which catches a debuff cast by hand
|
||||||
|
/// that the bot never announced.
|
||||||
|
/// </summary>
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A peer's map coordinates back as a host position; the cell is the outdoor cell they fall in.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The original's map-coordinate to outdoor landcell conversion.</summary>
|
||||||
|
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<string> 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 <on|off|status|overlay>");
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -40,6 +40,8 @@ public sealed class FakeAutomation : IAutomationSurface
|
||||||
|
|
||||||
public FakeLogin Login { get; } = new();
|
public FakeLogin Login { get; } = new();
|
||||||
|
|
||||||
|
public FakeNetwork Network { get; } = new();
|
||||||
|
|
||||||
ICharacterInfo IAutomationSurface.Character => Character;
|
ICharacterInfo IAutomationSurface.Character => Character;
|
||||||
ISpellCatalog IAutomationSurface.Spells => Spells;
|
ISpellCatalog IAutomationSurface.Spells => Spells;
|
||||||
IMagicCommands IAutomationSurface.Magic => Magic;
|
IMagicCommands IAutomationSurface.Magic => Magic;
|
||||||
|
|
@ -55,6 +57,7 @@ public sealed class FakeAutomation : IAutomationSurface
|
||||||
ITradeAutomation IAutomationSurface.Trade => Trade;
|
ITradeAutomation IAutomationSurface.Trade => Trade;
|
||||||
IVendorAutomation IAutomationSurface.Vendor => Vendor;
|
IVendorAutomation IAutomationSurface.Vendor => Vendor;
|
||||||
ILoginAutomation IAutomationSurface.Login => Login;
|
ILoginAutomation IAutomationSurface.Login => Login;
|
||||||
|
INetworkAutomation IAutomationSurface.Network => Network;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>A settable <see cref="ITradeAutomation"/>: a test drives <see cref="Raise*"/> and inspects <see cref="Calls"/>.</summary>
|
/// <summary>A settable <see cref="ITradeAutomation"/>: a test drives <see cref="Raise*"/> and inspects <see cref="Calls"/>.</summary>
|
||||||
|
|
@ -500,6 +503,8 @@ public sealed class FakeMagic : IMagicCommands
|
||||||
{
|
{
|
||||||
public bool IsCasting { get; set; }
|
public bool IsCasting { get; set; }
|
||||||
|
|
||||||
|
public PluginCastCompletion LastCompletion { get; set; }
|
||||||
|
|
||||||
public List<(uint SpellId, uint TargetObjectId)> Casts { get; } = [];
|
public List<(uint SpellId, uint TargetObjectId)> Casts { get; } = [];
|
||||||
|
|
||||||
/// <summary>What <see cref="RequestCast(uint)"/>/<see cref="RequestCast(uint,uint)"/> answer -- a test sets this to simulate a refusal that isn't a plain Cast()==false.</summary>
|
/// <summary>What <see cref="RequestCast(uint)"/>/<see cref="RequestCast(uint,uint)"/> answer -- a test sets this to simulate a refusal that isn't a plain Cast()==false.</summary>
|
||||||
|
|
|
||||||
45
tests/OpenAC.MosswartMassacre.Tests/Fakes/FakeNetwork.cs
Normal file
45
tests/OpenAC.MosswartMassacre.Tests/Fakes/FakeNetwork.cs
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
using AcDream.Plugin.Abstractions;
|
||||||
|
|
||||||
|
namespace OpenAC.MosswartMassacre.Tests.Fakes;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A peer network that records what a plugin imports and hands back the casts
|
||||||
|
/// this client announced.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FakeNetwork : INetworkAutomation
|
||||||
|
{
|
||||||
|
public bool IsAvailable { get; set; } = true;
|
||||||
|
|
||||||
|
public List<PluginNetworkClient> ImportedClients { get; } = [];
|
||||||
|
|
||||||
|
public List<(uint Caster, uint Target, uint Spell, int Skill, double Seconds, bool Landed)> ImportedCasts { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>What this client announced, as <see cref="CaptureOwnCasts"/> reports it.</summary>
|
||||||
|
public List<PluginPeerCast> OwnCasts { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>Whether an import is accepted.</summary>
|
||||||
|
public bool AcceptImports { get; set; } = true;
|
||||||
|
|
||||||
|
public IReadOnlyList<PluginNetworkClient> CaptureClients() => ImportedClients;
|
||||||
|
|
||||||
|
public IReadOnlyList<PluginPeerCast> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
280
tests/OpenAC.MosswartMassacre.Tests/Sharing/VitalSharingTests.cs
Normal file
280
tests/OpenAC.MosswartMassacre.Tests/Sharing/VitalSharingTests.cs
Normal file
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The share_* frames exactly as the original's VitalSharingTracker wrote
|
||||||
|
/// them, and the way frames from other computers reach the host's peer
|
||||||
|
/// network.
|
||||||
|
/// </summary>
|
||||||
|
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<PluginHarness>? 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<VitalSharing>();
|
||||||
|
|
||||||
|
[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<VitalSharing>().IsActive);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue