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:
Erik 2026-09-25 12:24:00 +02:00
parent 5799ea956e
commit 78813cf861
9 changed files with 1042 additions and 4 deletions

View file

@ -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<string> _inbound = new();
@ -36,6 +37,7 @@ internal sealed class BackendClient : IDisposable
private CancellationTokenSource? _cts;
private Task? _loop;
private Channel<string>? _outbound;
private Task? _sendLoop;
public BackendClient(IPluginLogger log)
{
@ -79,18 +81,35 @@ internal sealed class BackendClient : IDisposable
{
Task? loop;
CancellationTokenSource? cts;
Channel<string>? 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)

View file

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

View file

@ -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();

View 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();
}

View 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");
}
}
}