Inventory: full_inventory, inventory_delta, equipment_cantrip_state

The backend's inventory views and mana panel are fed by three streams the
original sent: the whole inventory after login, at logoff and on
/mm sendinventory; an add, update or remove per item as it enters,
changes in or leaves the packs; and the per-item cantrip state of worn and
wielded gear. This ports all three onto the host's object events, with the
frames built exactly as the original built them.

Behaviour kept: adds and removes go out at once, changes are coalesced into
one update per item on a two-to-five-minute timer re-rolled per flush; the
first login appraises everything worth appraising and waits before
sending; later logins merge in the saved appraisal data (kept in the
character's storage, as the original kept a file per character); the
mana panel debounces a quarter second and projects mana down between
reports.

Adapted to the host: appraisals go through a tick-driven queue, one at a
time, retrying while the host's single slot is busy; the login upload waits
for the inventory to stop arriving and for the socket, so it is not
written into a closed socket; the logoff upload subscribes ahead of the
root so the socket is still open; enchantment changes are polled once a
second (the host has no event); an item-cast enchantment is the one that
never runs out. A request the server never answers no longer holds the
upload back for good.

The Prismatic Taper count telemetry reads is InventoryFeature.PrismaticTaperCount.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-25 12:39:55 +02:00
parent f4a44bd9fc
commit 7a6b0a7f9d
11 changed files with 2206 additions and 2 deletions

View file

@ -1,4 +1,5 @@
using OpenAC.MosswartMassacre.Backend;
using OpenAC.MosswartMassacre.Inventory;
namespace OpenAC.MosswartMassacre;
@ -10,8 +11,13 @@ internal static class FeatureCatalog
{
public static void AddFeatures(MmContext context, BackendSession session, List<IMmFeature> features)
{
_ = context;
_ = session;
_ = features;
// Inventory: full_inventory, inventory_delta, equipment_cantrip_state,
// and the taper count telemetry reads (InventoryFeature.PrismaticTaperCount).
var inventoryReader = new InventoryReader(context, HostItemHeaderSource.Instance);
var identifyQueue = new IdentifyQueue(() => context.Automation);
features.Add(new InventoryFeature(context, inventoryReader, identifyQueue));
features.Add(new EquipmentCantripStateFeature(context, inventoryReader));
}
}

View file

@ -0,0 +1,425 @@
using AcDream.Plugin.Abstractions;
using OpenAC.MosswartMassacre.Backend;
namespace OpenAC.MosswartMassacre.Inventory;
/// <summary>
/// The backend's mana panel: for every spell-bearing item the character
/// wears or wields, whether its spells are running, its mana and how long
/// that mana lasts, sent as <c>equipment_cantrip_state</c> a quarter second
/// after the last relevant change.
/// </summary>
/// <remarks>
/// <para>
/// An item is <c>unknown</c> until appraised, <c>not_activatable</c> (and left
/// out) without spells or mana, <c>not_active</c> when out of mana or when
/// one of its beneficial spells is neither running on the item nor matched
/// by an equal-or-stronger item spell on the character, and <c>active</c>
/// otherwise. The mana of an active item is projected down from the moment
/// its mana was last reported, at one point per burn interval.
/// </para>
/// <para>
/// The item is read through its item record, so every value is taken from
/// the same keys the original read. The host raises no event when the
/// character's enchantments change, so they are compared once a second.
/// </para>
/// </remarks>
internal sealed class EquipmentCantripStateFeature : IMmFeature
{
public static readonly TimeSpan Debounce = TimeSpan.FromMilliseconds(250);
public static readonly TimeSpan EnchantmentPoll = TimeSpan.FromSeconds(1);
private const int CloakSlot = 134217728;
private const int AmmoSlot = 8388608;
private readonly MmContext _context;
private readonly InventoryReader _reader;
private readonly Action<PluginObjectChange> _onObjectChanged;
private readonly Action<double> _onTick;
private readonly Dictionary<uint, DateTime> _lastManaUpdateByItemId = [];
private readonly Dictionary<uint, int> _lastSeenMana = [];
private IDisposable? _debounce;
private bool _active;
private double _enchantmentPollSeconds;
private string _enchantmentSignature = string.Empty;
public EquipmentCantripStateFeature(MmContext context, InventoryReader reader)
{
_context = context;
_reader = reader;
_onObjectChanged = OnObjectChanged;
_onTick = OnTick;
_context.Host.Events.ObjectChanged += _onObjectChanged;
_context.Host.Events.Tick += _onTick;
}
public void OnLogin()
{
Cleanup();
_active = true;
try
{
foreach (PluginInventoryItem item in _reader.CaptureOwned())
RememberManaSnapshotTime(item.ObjectId);
_enchantmentSignature = EnchantmentSignature();
RequestRefresh();
}
catch (Exception ex)
{
_context.Host.Log.Warn($"[EquipCantrip] Initialize error: {ex.Message}");
}
}
public void OnLogoff()
{
_active = false;
Cleanup();
}
public void Dispose()
{
_context.Host.Events.ObjectChanged -= _onObjectChanged;
_context.Host.Events.Tick -= _onTick;
Cleanup();
}
private void Cleanup()
{
_debounce?.Dispose();
_debounce = null;
_lastManaUpdateByItemId.Clear();
_lastSeenMana.Clear();
_enchantmentPollSeconds = 0d;
_enchantmentSignature = string.Empty;
}
private void OnTick(double deltaSeconds)
{
if (!_active)
return;
_enchantmentPollSeconds += deltaSeconds;
if (_enchantmentPollSeconds < EnchantmentPoll.TotalSeconds)
return;
_enchantmentPollSeconds = 0d;
string signature = EnchantmentSignature();
if (signature != _enchantmentSignature)
{
_enchantmentSignature = signature;
RequestRefresh();
}
}
private string EnchantmentSignature() =>
string.Join(',', _context.Automation.Character.ActiveEnchantments.Select(e => e.SpellId).Order());
private void OnObjectChanged(PluginObjectChange change)
{
if (!_active)
return;
try
{
switch (change.Kind)
{
case PluginObjectChangeKind.Created:
if (change.Current is { } created && IsPotentiallyRelevant(created))
{
RememberManaSnapshotTime(created.ObjectId);
RequestRefresh();
}
break;
case PluginObjectChangeKind.Released:
bool wasTracked = _lastManaUpdateByItemId.Remove(change.ObjectId);
_lastSeenMana.Remove(change.ObjectId);
if (wasTracked || (change.Current is { } released && IsPotentiallyRelevant(released)))
RequestRefresh();
break;
default:
if (change.Current is not { } changed)
break;
bool manaChanged = IsPotentiallyRelevant(changed) && ManaChanged(changed.ObjectId);
if (change.Kind == PluginObjectChangeKind.IdentReceived || manaChanged)
RememberManaSnapshotTime(changed.ObjectId);
if (IsPotentiallyRelevant(changed))
RequestRefresh();
break;
}
}
catch (Exception ex)
{
_context.Host.Log.Warn($"[EquipCantrip] Change error: {ex.Message}");
}
}
/// <summary>The host has no mana-change event; a changed current-mana value is one.</summary>
private bool ManaChanged(uint objectId)
{
int mana = CurrentMana(objectId);
return _lastSeenMana.TryGetValue(objectId, out int previous) && previous != mana;
}
private int CurrentMana(uint objectId) =>
_context.Automation.Items.TryCaptureProperties(objectId, out PluginItemProperties properties)
&& properties.Ints is { } ints
&& ints.TryGetValue(DecalKeys.RawCurrentMana, out int mana)
? mana
: 0;
private void RememberManaSnapshotTime(uint objectId)
{
_lastManaUpdateByItemId[objectId] = _context.Clock.UtcNow;
_lastSeenMana[objectId] = CurrentMana(objectId);
}
private bool IsPotentiallyRelevant(PluginWorldObject wo) =>
(wo.WielderObjectId != 0u && wo.WielderObjectId == _reader.CharacterId)
|| _lastManaUpdateByItemId.ContainsKey(wo.ObjectId);
private void RequestRefresh()
{
_debounce?.Dispose();
_debounce = _context.Scheduler.Delay(Debounce, SendSnapshot);
}
internal void SendSnapshot()
{
_debounce = null;
try
{
if (!_active || !_context.Settings.Current.WebSocketEnabled)
return;
object[] items = BuildSnapshotItems().ToArray();
_context.Send(new
{
type = "equipment_cantrip_state",
timestamp = Wire.Timestamp(_context.Clock),
character_name = _context.CharacterName,
items = items,
});
}
catch (Exception ex)
{
_context.Host.Log.Warn($"[EquipCantrip] Send error: {ex.Message}");
}
}
private List<object> BuildSnapshotItems()
{
var snapshotItems = new List<object>();
List<PluginSpellInfo> activeCharacterSpells = GetActiveCharacterItemSpellData();
foreach (PluginInventoryItem item in _reader.CaptureOwned())
{
if (item.EquippedLocation == 0u)
continue;
MyWorldObject wo = _reader.Build(item);
if (!ShouldDisplayInManaPanel(wo, item))
continue;
object? snapshot = BuildItemSnapshot(wo, activeCharacterSpells);
if (snapshot is not null)
snapshotItems.Add(snapshot);
}
return snapshotItems;
}
/// <summary>
/// The character's item-cast enchantments, the ones with no end. The host
/// reports those as never running out.
/// </summary>
private List<PluginSpellInfo> GetActiveCharacterItemSpellData()
{
var activeSpells = new List<PluginSpellInfo>();
foreach (PluginActiveEnchantment enchantment in _context.Automation.Character.ActiveEnchantments)
{
if (!double.IsPositiveInfinity(enchantment.SecondsRemaining) && enchantment.SecondsRemaining > 0d)
continue;
if (_context.Automation.Spells.TryGet(enchantment.SpellId, out PluginSpellInfo spell))
activeSpells.Add(spell);
}
return activeSpells;
}
private object? BuildItemSnapshot(MyWorldObject wo, List<PluginSpellInfo> activeCharacterSpells)
{
ItemState itemState = RecalculateState(wo, activeCharacterSpells);
if (itemState == ItemState.NotActivatable)
return null;
int maximumMana = Int(wo, DecalKeys.RawMaximumMana, 0);
int currentMana = itemState == ItemState.Unknown ? 0 : CalculateCurrentMana(wo, itemState);
double? manaTimeRemainingSeconds = null;
if (itemState == ItemState.Active)
{
TimeSpan remaining = CalculateManaTimeRemaining(wo, currentMana);
manaTimeRemainingSeconds = remaining.TotalSeconds;
}
return new
{
item_id = wo.Id,
state = ToWireState(itemState),
current_mana = itemState == ItemState.Unknown ? (int?)null : currentMana,
max_mana = itemState == ItemState.Unknown ? (int?)null : maximumMana,
mana_time_remaining_seconds = manaTimeRemainingSeconds,
};
}
private ItemState RecalculateState(MyWorldObject wo, List<PluginSpellInfo> activeCharacterSpells)
{
if (!wo.HasIdData)
return ItemState.Unknown;
if (Int(wo, DecalKeys.SpellCount, 0) == 0 || Int(wo, DecalKeys.RawMaximumMana, 0) == 0)
return ItemState.NotActivatable;
if (Int(wo, DecalKeys.RawCurrentMana, 0) == 0)
return ItemState.NotActive;
foreach (int spellOnItemId in wo.Spells)
{
if (wo.IntValues.TryGetValue(DecalKeys.SpellDid, out int associated) && associated == spellOnItemId)
continue;
if (!_context.Automation.Spells.TryGet(unchecked((uint)spellOnItemId), out PluginSpellInfo spellOnItem))
continue;
if (spellOnItem.IsDebuff || spellOnItem.IsOffensive)
continue;
bool thisSpellIsActive = false;
foreach (int activeSpellId in wo.ActiveSpells)
{
if (_context.Automation.Spells.TryGet(unchecked((uint)activeSpellId), out PluginSpellInfo activeSpellOnItem)
&& SpellMatchesOrSurpasses(activeSpellOnItem, spellOnItem))
{
thisSpellIsActive = true;
break;
}
}
if (thisSpellIsActive)
continue;
foreach (PluginSpellInfo activeSpellOnChar in activeCharacterSpells)
{
if (SpellMatchesOrSurpasses(activeSpellOnChar, spellOnItem))
{
thisSpellIsActive = true;
break;
}
}
if (!thisSpellIsActive)
return ItemState.NotActive;
}
return ItemState.Active;
}
private static bool SpellMatchesOrSurpasses(PluginSpellInfo activeSpell, PluginSpellInfo requiredSpell) =>
activeSpell.Family == requiredSpell.Family && activeSpell.Difficulty >= requiredSpell.Difficulty;
private int CalculateCurrentMana(MyWorldObject wo, ItemState itemState)
{
if (itemState == ItemState.Unknown || itemState == ItemState.NotActivatable)
return 0;
if (itemState == ItemState.NotActive)
return Int(wo, DecalKeys.RawCurrentMana, 0);
double secondsPerBurn = GetSecondsPerBurn(wo);
if (secondsPerBurn <= 0)
return Int(wo, DecalKeys.RawCurrentMana, 0);
uint objectId = unchecked((uint)wo.Id);
DateTime now = _context.Clock.UtcNow;
if (!_lastManaUpdateByItemId.TryGetValue(objectId, out DateTime lastUpdate))
{
lastUpdate = now;
_lastManaUpdateByItemId[objectId] = lastUpdate;
}
TimeSpan timeSinceLastUpdate = now - lastUpdate;
int burnedMana = (int)(timeSinceLastUpdate.TotalSeconds / secondsPerBurn);
int currentMana = Int(wo, DecalKeys.RawCurrentMana, 0);
if (burnedMana > currentMana)
burnedMana = currentMana;
return Math.Max(currentMana - burnedMana, 0);
}
private static TimeSpan CalculateManaTimeRemaining(MyWorldObject wo, int calculatedCurrentMana)
{
double secondsPerBurn = GetSecondsPerBurn(wo);
if (secondsPerBurn <= 0)
return new TimeSpan(99, 99, 0);
return TimeSpan.FromSeconds(calculatedCurrentMana * secondsPerBurn);
}
private static double GetSecondsPerBurn(MyWorldObject wo)
{
if (!wo.HasIdData)
return 0;
double manaRateOfChange = wo.DoubleValues.TryGetValue(DecalKeys.RawManaRate, out double rate) ? rate : 0;
if (manaRateOfChange == 0)
return 0;
return ((int)Math.Ceiling(-0.2 / manaRateOfChange)) * 5;
}
private bool ShouldDisplayInManaPanel(MyWorldObject wo, in PluginInventoryItem item)
{
if (!ItemIsEquippedByMe(wo, item))
return false;
if (!string.IsNullOrEmpty(wo.Name) && wo.Name.Contains("Aetheria", StringComparison.OrdinalIgnoreCase))
return false;
if (Int(wo, DecalKeys.EquipableSlots, 0) == CloakSlot)
return false;
if (Int(wo, DecalKeys.RawEquippedSlots, 0) == AmmoSlot)
return false;
return true;
}
/// <summary>
/// Worn or wielded by this character. The framework's slot key read -1
/// for anything not in a pack slot, and then the container decided.
/// </summary>
private bool ItemIsEquippedByMe(MyWorldObject wo, in PluginInventoryItem item)
{
int equippedSlots = Int(wo, DecalKeys.RawEquippedSlots, 0);
if (equippedSlots <= 0)
return false;
if (item.ContainerSlot == -1)
return wo.ContainerId == unchecked((int)_reader.CharacterId);
return true;
}
private static int Int(MyWorldObject wo, int key, int defaultValue) =>
wo.IntValues.TryGetValue(key, out int value) ? value : defaultValue;
private static string ToWireState(ItemState itemState) => itemState switch
{
ItemState.Active => "active",
ItemState.NotActive => "not_active",
ItemState.NotActivatable => "not_activatable",
_ => "unknown",
};
private enum ItemState
{
Unknown,
NotActivatable,
Active,
NotActive,
}
}

View file

@ -0,0 +1,123 @@
using AcDream.Plugin.Abstractions;
namespace OpenAC.MosswartMassacre.Inventory;
/// <summary>
/// Appraises the player's items one at a time. The original fired an
/// appraisal request for every item at once and its framework queued them;
/// the host here has a single appraisal slot and answers
/// <see cref="PluginItemCommandStatus.Busy"/> while it is taken, so the queue
/// asks again on a later tick instead.
/// </summary>
/// <remarks>
/// A request is finished when the host reports it answered, gave up on it,
/// or when it has been outstanding for <see cref="Timeout"/>. The owner
/// pumps the queue from the tick; nothing here runs on another thread.
/// </remarks>
internal sealed class IdentifyQueue
{
/// <summary>How long one request may stay unanswered before it is given up.</summary>
public static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10);
private readonly Func<IAutomationSurface> _automation;
private readonly LinkedList<uint> _pending = new();
private readonly HashSet<uint> _queued = [];
private uint _inFlight;
private double _inFlightSeconds;
private long _appraisalRevisionAtRequest;
public IdentifyQueue(Func<IAutomationSurface> automation)
{
_automation = automation;
}
/// <summary>Raised with the item id and whether the appraisal arrived, once per finished request.</summary>
public event Action<uint, bool>? Finished;
/// <summary>True when nothing is waiting and nothing is outstanding.</summary>
public bool IsIdle => _inFlight == 0u && _pending.Count == 0;
/// <summary>Requests queued or outstanding.</summary>
public int Count => _pending.Count + (_inFlight != 0u ? 1 : 0);
/// <summary>Queues one item unless it is already queued or outstanding.</summary>
public void Enqueue(uint objectId)
{
if (objectId == 0u || objectId == _inFlight || !_queued.Add(objectId))
return;
_pending.AddLast(objectId);
}
/// <summary>Drops everything, for a logoff.</summary>
public void Clear()
{
_pending.Clear();
_queued.Clear();
_inFlight = 0u;
_inFlightSeconds = 0d;
}
/// <summary>The host delivered appraisal data for an object.</summary>
public void OnIdentReceived(uint objectId)
{
if (objectId != 0u && objectId == _inFlight)
Finish(success: true);
}
/// <summary>Advances the queue; call once per tick.</summary>
public void Pump(double deltaSeconds)
{
if (_inFlight != 0u)
{
_inFlightSeconds += Math.Max(0d, deltaSeconds);
PluginAppraisalState state = _automation().Loot.Appraisal;
if (state.Revision != _appraisalRevisionAtRequest)
{
if (state.CurrentObjectId == _inFlight)
{
Finish(success: true);
}
else if (state.LastAbandonedObjectId == _inFlight)
{
Finish(success: false);
}
}
if (_inFlight != 0u && _inFlightSeconds >= Timeout.TotalSeconds)
Finish(success: false);
if (_inFlight != 0u)
return;
}
while (_pending.First is { } next)
{
uint objectId = next.Value;
IAutomationSurface automation = _automation();
PluginItemCommandResult result = automation.Objects.Identify(objectId);
if (result.Status == PluginItemCommandStatus.Busy)
return;
_pending.RemoveFirst();
_queued.Remove(objectId);
if (result.Status == PluginItemCommandStatus.Started)
{
_inFlight = objectId;
_inFlightSeconds = 0d;
// Read after the request, so the request's own change to the
// slot is not taken for its answer.
_appraisalRevisionAtRequest = automation.Loot.Appraisal.Revision;
return;
}
// Refused outright: the item is gone or cannot be appraised.
Finished?.Invoke(objectId, false);
}
}
private void Finish(bool success)
{
uint objectId = _inFlight;
_inFlight = 0u;
_inFlightSeconds = 0d;
Finished?.Invoke(objectId, success);
}
}

View file

@ -0,0 +1,602 @@
using AcDream.Plugin.Abstractions;
using Newtonsoft.Json;
using OpenAC.MosswartMassacre.Backend;
namespace OpenAC.MosswartMassacre.Inventory;
/// <summary>
/// The player's inventory on the backend: the whole inventory
/// (<c>full_inventory</c>) after login, at logoff and on
/// <c>/mm sendinventory</c>; every item entering, changing in or leaving it
/// (<c>inventory_delta</c>); and the Prismatic Taper count the telemetry
/// frame carries.
/// </summary>
/// <remarks>
/// <para>
/// Adds and removes go out at once. Changes to an item already tracked are
/// coalesced and sent as one <c>update</c> per changed item on a randomized
/// two-to-five-minute timer, re-rolled after every flush so a fleet of
/// clients never flushes in step.
/// </para>
/// <para>
/// Login waits until the inventory has stopped arriving (no new owned item
/// for <see cref="SettleQuiet"/>) and, while streaming, for the socket, so
/// the login inventory is not written into a closed socket. Until then new
/// items are tracked silently, as the original's login stream arrived before
/// its socket was open. Items worth appraising are appraised one at a time
/// through <see cref="IdentifyQueue"/>; the original asked for all of them at
/// once.
/// </para>
/// <para>
/// A copy of the last inventory sent is kept in the character's storage, as
/// the original kept a file per character. It decides whether a login first
/// appraises everything and waits, and it keeps appraisal data from an
/// earlier session for items not yet appraised in this one.
/// </para>
/// </remarks>
internal sealed class InventoryFeature : IMmFeature
{
public const string CacheKey = "inventory.json";
/// <summary>How long no new owned item must arrive before login counts the inventory as complete.</summary>
public static readonly TimeSpan SettleQuiet = TimeSpan.FromSeconds(3);
/// <summary>The longest login waits for the inventory to stop arriving.</summary>
public static readonly TimeSpan SettleLimit = TimeSpan.FromSeconds(20);
/// <summary>The longest the login inventory waits for the socket before it is written anyway.</summary>
public static readonly TimeSpan ConnectLimit = TimeSpan.FromSeconds(30);
public const int FlushBaseMs = 120000;
public const int FlushJitterMs = 180000;
private readonly MmContext _context;
private readonly InventoryReader _reader;
private readonly IdentifyQueue _identify;
private readonly Random _rng;
private readonly Action<PluginObjectChange> _onObjectChanged;
private readonly Action<double> _onTick;
private readonly Action _onHostLogoff;
// Live tracking (the original's live inventory tracker).
private readonly HashSet<uint> _tracked = [];
private readonly HashSet<uint> _dirty = [];
private IDisposable? _flushTimer;
// Whole-inventory uploads (the original's inventory logger).
private readonly HashSet<uint> _requested = [];
private bool _waitingForLoginIds;
private bool _waitingForForcedIds;
private bool _active;
private bool _settled;
private bool _loginUploadPending;
private double _quietSeconds;
private double _settleSeconds;
private double _connectSeconds;
private bool _taperDirty = true;
private int _taperCount;
public InventoryFeature(MmContext context, InventoryReader reader, IdentifyQueue identify, Random? rng = null)
{
_context = context;
_reader = reader;
_identify = identify;
_rng = rng ?? new Random();
IsBackendReady = () => context.Backend.IsConnected;
_onObjectChanged = OnObjectChanged;
_onTick = OnTick;
_onHostLogoff = OnHostLogoff;
_context.Host.Events.ObjectChanged += _onObjectChanged;
_context.Host.Events.Tick += _onTick;
// Subscribed while the root builds its features, which is before the
// root subscribes its own logoff; so this runs while the socket is
// still open, and the logoff inventory can still be sent.
_context.Host.Events.Logoff += _onHostLogoff;
context.Commands.Register("sendinventory", _ => ForceInventoryUpload(), "Force full inventory upload");
}
/// <summary>
/// The Prismatic Tapers in the packs, for the telemetry frame's
/// <c>prismatic_taper_count</c>. Read it on the tick thread; it recounts
/// only after an item changed.
/// </summary>
public int PrismaticTaperCount
{
get
{
if (_active && _taperDirty)
{
_taperCount = _reader.CountPrismaticTapers();
_taperDirty = false;
}
return _taperCount;
}
}
/// <summary>True when the backend can take the login inventory. Tests replace it.</summary>
internal Func<bool> IsBackendReady { get; set; }
internal bool IsSettled => _settled;
internal int TrackedCount => _tracked.Count;
internal int DirtyCount => _dirty.Count;
public void OnLogin()
{
ResetState();
_active = true;
_reader.Reset();
_taperDirty = true;
// The original's item events were wired before login, so every item
// of the login stream that was worth appraising was asked for.
foreach (PluginInventoryItem item in _reader.CaptureOwned())
{
_tracked.Add(item.ObjectId);
if (InventoryReader.NeedsIdent(item.ObjectClass, item.Name) && !_reader.IsIdentified(item.ObjectId))
_identify.Enqueue(item.ObjectId);
}
}
public void OnLogoff()
{
_active = false;
ResetState();
}
public void Dispose()
{
_context.Host.Events.ObjectChanged -= _onObjectChanged;
_context.Host.Events.Tick -= _onTick;
_context.Host.Events.Logoff -= _onHostLogoff;
ResetState();
}
private void ResetState()
{
_flushTimer?.Dispose();
_flushTimer = null;
_tracked.Clear();
_dirty.Clear();
_requested.Clear();
_identify.Clear();
_waitingForLoginIds = false;
_waitingForForcedIds = false;
_settled = false;
_loginUploadPending = false;
_quietSeconds = 0d;
_settleSeconds = 0d;
_connectSeconds = 0d;
}
private bool InventoryLog => _context.Settings.Current.InventoryLog;
private void OnTick(double deltaSeconds)
{
if (!_active)
return;
_identify.Pump(deltaSeconds);
if (!_settled)
{
_quietSeconds += deltaSeconds;
_settleSeconds += deltaSeconds;
if (_quietSeconds >= SettleQuiet.TotalSeconds || _settleSeconds >= SettleLimit.TotalSeconds)
Settle();
return;
}
if (_loginUploadPending)
{
_connectSeconds += deltaSeconds;
if (!_context.Settings.Current.WebSocketEnabled || IsBackendReady() || _connectSeconds >= ConnectLimit.TotalSeconds)
{
_loginUploadPending = false;
RunLoginUpload();
}
return;
}
if ((_waitingForLoginIds || _waitingForForcedIds) && _identify.IsIdle)
CompleteWaitingUpload();
}
/// <summary>The inventory has arrived: start live tracking and the login upload.</summary>
private void Settle()
{
_settled = true;
foreach (PluginInventoryItem item in _reader.CaptureOwned())
_tracked.Add(item.ObjectId);
ArmFlushTimer();
_loginUploadPending = true;
_connectSeconds = 0d;
}
/// <summary>The original's inventory logger at login.</summary>
private void RunLoginUpload()
{
if (!InventoryLog)
return;
if (ReadCache() is null)
{
_context.Chat.Write("Requesting id information for all armor/weapon inventory. This will take a few minutes...");
foreach (PluginInventoryItem item in _reader.CaptureOwned())
{
if (!_reader.IsIdentified(item.ObjectId) && InventoryReader.NeedsIdent(item.ObjectClass, item.Name))
_identify.Enqueue(item.ObjectId);
}
_waitingForLoginIds = true;
}
else
{
DumpInventory(requestIdsIfMissing: true);
}
}
/// <summary>
/// Every appraisal asked for has finished. The original waited until
/// every item worth appraising had been appraised, so one item the server
/// would not appraise held the upload back for good; this sends once the
/// requests have all been answered or given up.
/// </summary>
private void CompleteWaitingUpload()
{
bool wasForceUpload = _waitingForForcedIds;
_waitingForLoginIds = false;
_waitingForForcedIds = false;
DumpInventory();
if (wasForceUpload)
_context.Chat.Write("[INV] All items identified. Inventory upload completed.");
else
_context.Chat.Write("Requesting id information for all armor/weapon inventory completed. Log file written.");
}
/// <summary><c>/mm sendinventory</c>: appraise what needs it, wait, then send everything.</summary>
internal void ForceInventoryUpload()
{
try
{
if (!InventoryLog)
{
_context.Chat.Write("[INV] Inventory logging is disabled");
return;
}
if (!_context.Settings.Current.WebSocketEnabled)
{
_context.Chat.Write("[INV] WebSocket streaming is disabled");
return;
}
if (!_active)
{
_context.Chat.Write("[INV] Inventory system not initialized");
return;
}
int needsId = 0;
int totalItems = 0;
foreach (PluginInventoryItem item in _reader.CaptureOwned())
{
totalItems++;
if (!_reader.IsIdentified(item.ObjectId) && InventoryReader.NeedsIdent(item.ObjectClass, item.Name))
{
_identify.Enqueue(item.ObjectId);
needsId++;
}
}
if (needsId > 0)
{
_waitingForForcedIds = true;
_context.Chat.Write($"[INV] Scanning {totalItems} items, requesting ID for {needsId}. Waiting for identification...");
}
else
{
_context.Chat.Write($"[INV] All {totalItems} items already identified. Sending...");
DumpInventory();
_context.Chat.Write("[INV] Inventory upload completed");
}
}
catch (Exception ex)
{
_context.Chat.Write($"[INV] Force upload failed: {ex.Message}");
}
}
private void OnHostLogoff()
{
if (!_active || !InventoryLog)
return;
try
{
DumpInventory();
}
catch (Exception ex)
{
_context.Chat.Write($"[INV] {ex}");
}
}
/// <summary>
/// The original's inventory dump: every owned item, merged with what the
/// saved copy knew about it, saved, and sent as <c>full_inventory</c>.
/// </summary>
internal void DumpInventory(bool requestIdsIfMissing = false)
{
List<MyWorldObject> previouslySaved = ReadCache() ?? [];
var currentList = new List<MyWorldObject>();
foreach (PluginInventoryItem item in _reader.CaptureOwned())
{
MyWorldObject current = _reader.Build(item);
bool needsId = requestIdsIfMissing
&& !current.HasIdData
&& InventoryReader.NeedsIdent(item.ObjectClass, item.Name);
MyWorldObject? previous = previouslySaved.Find(
prev => prev.Id == current.Id && prev.ObjectClass == current.ObjectClass);
if (previous is not null)
{
if (needsId && !previous.HasIdData)
{
_identify.Enqueue(item.ObjectId);
currentList.Add(current);
}
else
{
currentList.Add(DecalItemBuilder.Combine(previous, current));
}
continue;
}
if (needsId)
_identify.Enqueue(item.ObjectId);
currentList.Add(current);
}
WriteCache(currentList);
if (_context.Settings.Current.WebSocketEnabled)
{
_context.Send(new
{
type = "full_inventory",
timestamp = Wire.Timestamp(_context.Clock),
character_name = _context.CharacterName,
item_count = currentList.Count,
items = currentList,
});
_context.Chat.Write("Inventory sent to MosswartOverlord");
}
}
private List<MyWorldObject>? ReadCache()
{
IPluginStorage? scope = CacheScope();
string? text = scope?.ReadText(CacheKey);
if (text is null)
return null;
try
{
return JsonConvert.DeserializeObject<List<MyWorldObject>>(text) ?? [];
}
catch (JsonException)
{
_context.Chat.Write("Inventory file is corrupt.");
return [];
}
}
private void WriteCache(List<MyWorldObject> items)
{
IPluginStorage? scope = CacheScope();
if (scope is null)
return;
try
{
scope.WriteText(CacheKey, JsonConvert.SerializeObject(items, Formatting.Indented));
}
catch (Exception ex)
{
_context.Host.Log.Warn($"The inventory copy could not be saved: {ex.Message}");
}
}
private IPluginStorage? CacheScope()
{
IPluginStorage storage = _context.Host.Storage;
return storage.IsAvailable
? storage.OpenScope(PluginStorageScope.Character(_context.CharacterName))
: null;
}
private void OnObjectChanged(PluginObjectChange change)
{
if (!_active)
return;
try
{
if (change.Kind == PluginObjectChangeKind.IdentReceived)
_identify.OnIdentReceived(change.ObjectId);
PluginWorldObject? current = change.Current;
if (change.Kind == PluginObjectChangeKind.Released
|| current is null
|| string.Equals(current.Value.Name, InventoryReader.PrismaticTaper, StringComparison.OrdinalIgnoreCase))
{
_taperDirty = true;
}
if (change.Kind == PluginObjectChangeKind.Released)
{
OnRelease(change.ObjectId);
return;
}
if (current is not { } wo)
return;
if (!_settled)
{
OnLoginStreamItem(wo);
return;
}
if (change.Kind == PluginObjectChangeKind.Created)
OnCreate(wo);
else
OnChange(wo);
RequestIdForMainPackItem(wo);
}
catch (Exception ex)
{
_context.Host.Log.Warn($"[LiveInv] Error handling an item change: {ex.Message}");
}
}
/// <summary>An item of the login stream: tracked without an add, appraised if worth it.</summary>
private void OnLoginStreamItem(PluginWorldObject wo)
{
if (!_reader.IsPlayerInventory(wo.ContainerObjectId, wo.WielderObjectId))
return;
if (_tracked.Add(wo.ObjectId))
_quietSeconds = 0d;
if (!IsIdentified(wo) && InventoryReader.NeedsIdent(wo.ObjectClass, wo.Name))
_identify.Enqueue(wo.ObjectId);
}
private void OnCreate(PluginWorldObject wo)
{
if (!_reader.IsPlayerInventory(wo.ContainerObjectId, wo.WielderObjectId))
return;
if (_tracked.Contains(wo.ObjectId))
return;
AddTracked(wo);
}
private void OnChange(PluginWorldObject wo)
{
if (!_reader.IsPlayerInventory(wo.ContainerObjectId, wo.WielderObjectId))
{
// The item left the inventory.
if (_tracked.Remove(wo.ObjectId))
SendRemove(wo.ObjectId);
return;
}
if (!_tracked.Contains(wo.ObjectId))
AddTracked(wo);
else
_dirty.Add(wo.ObjectId);
}
private void OnRelease(uint objectId)
{
if (!_settled)
{
_tracked.Remove(objectId);
return;
}
if (_tracked.Remove(objectId))
SendRemove(objectId);
}
private void AddTracked(PluginWorldObject wo)
{
_tracked.Add(wo.ObjectId);
if (InventoryReader.TryFind(_reader.CaptureOwned(), wo.ObjectId, out PluginInventoryItem item))
SendDelta("add", _reader.Build(item));
if (!IsIdentified(wo) && InventoryReader.NeedsIdent(wo.ObjectClass, wo.Name))
_identify.Enqueue(wo.ObjectId);
}
/// <summary>
/// The inventory logger's own request: an unappraised item that lands in
/// the main pack (or is wielded) is appraised once.
/// </summary>
private void RequestIdForMainPackItem(PluginWorldObject wo)
{
if (!InventoryLog || _waitingForLoginIds || _waitingForForcedIds)
return;
uint container = wo.ContainerObjectId != 0u ? wo.ContainerObjectId : wo.WielderObjectId;
if (!IsIdentified(wo)
&& InventoryReader.NeedsIdent(wo.ObjectClass, wo.Name)
&& container == _reader.CharacterId
&& _requested.Add(wo.ObjectId))
{
_identify.Enqueue(wo.ObjectId);
}
}
private static bool IsIdentified(PluginWorldObject wo) => wo.HasAppraisalData && wo.LastIdTime != 0;
private void ArmFlushTimer()
{
_flushTimer?.Dispose();
_flushTimer = _context.Scheduler.Delay(TimeSpan.FromMilliseconds(NextFlushIntervalMs()), FlushDirtyItems);
}
/// <summary>Two minutes plus up to three more, re-rolled for every flush.</summary>
internal int NextFlushIntervalMs() => FlushBaseMs + _rng.Next(0, FlushJitterMs + 1);
/// <summary>Sends the current state of every item that changed since the last flush.</summary>
internal void FlushDirtyItems()
{
if (!_active)
return;
ArmFlushTimer();
if (_dirty.Count == 0)
return;
var ids = new List<uint>(_dirty);
_dirty.Clear();
IReadOnlyList<PluginInventoryItem> owned = _reader.CaptureOwned();
foreach (uint id in ids)
{
try
{
// An item that left since it was marked has had its remove.
if (!_tracked.Contains(id))
continue;
if (!InventoryReader.TryFind(owned, id, out PluginInventoryItem item)
|| !_reader.IsPlayerInventory(item.ContainerObjectId, item.WielderObjectId))
{
continue;
}
SendDelta("update", _reader.Build(item));
}
catch (Exception ex)
{
_context.Host.Log.Warn($"[LiveInv] Error flushing item {id}: {ex.Message}");
}
}
}
private void SendDelta(string action, MyWorldObject item) =>
_context.Send(new
{
type = "inventory_delta",
timestamp = Wire.Timestamp(_context.Clock),
character_name = _context.CharacterName,
action = action,
item = item,
});
private void SendRemove(uint objectId) =>
_context.Send(new
{
type = "inventory_delta",
timestamp = Wire.Timestamp(_context.Clock),
character_name = _context.CharacterName,
action = "remove",
item_id = unchecked((int)objectId),
});
}

View file

@ -0,0 +1,136 @@
using AcDream.Plugin.Abstractions;
namespace OpenAC.MosswartMassacre.Inventory;
/// <summary>
/// Reads the player's items from the host and turns them into item records.
/// Tick thread only, like every host read.
/// </summary>
/// <remarks>
/// The original's appraisal time was the wall clock in Unix seconds when the
/// appraisal arrived. The host keeps its own session clock instead, so the
/// reader stamps the wall clock the first time it sees each new appraisal of
/// an item and reports that stamp from then on.
/// </remarks>
internal sealed class InventoryReader
{
public const string PrismaticTaper = "Prismatic Taper";
private readonly MmContext _context;
private readonly IItemHeaderSource _headers;
private readonly Dictionary<uint, (int HostTime, int UnixSeconds)> _idStamps = [];
public InventoryReader(MmContext context, IItemHeaderSource headers)
{
_context = context;
_headers = headers;
}
public uint CharacterId => _context.Automation.Character.ObjectId;
/// <summary>Forgets the appraisal stamps, for a new login.</summary>
public void Reset() => _idStamps.Clear();
public IReadOnlyList<PluginInventoryItem> CaptureOwned() =>
_context.Automation.Items.CaptureOwnedItems();
public static bool TryFind(IReadOnlyList<PluginInventoryItem> items, uint objectId, out PluginInventoryItem item)
{
foreach (PluginInventoryItem candidate in items)
{
if (candidate.ObjectId != objectId)
continue;
item = candidate;
return true;
}
item = default;
return false;
}
/// <summary>
/// True once the item has been appraised this session. Any property data
/// makes the host report appraisal data, so the appraisal time is what
/// tells a real appraisal from a lone property update.
/// </summary>
public bool IsIdentified(uint objectId) =>
_context.Automation.Objects.TryGet(objectId, out PluginWorldObject world)
&& world.HasAppraisalData
&& world.LastIdTime != 0;
/// <summary>The item record for one owned item, as it stands now.</summary>
public MyWorldObject Build(in PluginInventoryItem item)
{
bool identified = false;
int lastIdTime = 0;
if (_context.Automation.Objects.TryGet(item.ObjectId, out PluginWorldObject world)
&& world.HasAppraisalData
&& world.LastIdTime != 0)
{
identified = true;
lastIdTime = Stamp(item.ObjectId, world.LastIdTime);
}
PluginItemProperties? properties =
_context.Automation.Items.TryCaptureProperties(item.ObjectId, out PluginItemProperties captured)
? captured
: null;
return DecalItemBuilder.Create(new ItemSource(
item, properties, identified, lastIdTime, _headers.Read(item)));
}
/// <summary>
/// The original's test for an item worth appraising: armor, clothing,
/// weapons, casters, jewelry, Aetheria and summoning essences.
/// </summary>
public static bool NeedsIdent(PluginObjectClass objectClass, string? name) =>
objectClass == PluginObjectClass.Armor
|| objectClass == PluginObjectClass.Clothing
|| objectClass == PluginObjectClass.MeleeWeapon
|| objectClass == PluginObjectClass.MissileWeapon
|| objectClass == PluginObjectClass.WandStaffOrb
|| objectClass == PluginObjectClass.Jewelry
|| (objectClass == PluginObjectClass.Gem && !string.IsNullOrEmpty(name) && name.Contains("Aetheria", StringComparison.Ordinal))
|| (objectClass == PluginObjectClass.Misc && !string.IsNullOrEmpty(name) && name.Contains("Essence", StringComparison.Ordinal));
/// <summary>
/// The original's inventory test: the item sits in (or is wielded by) the
/// character, or sits in a pack the character holds.
/// </summary>
public bool IsPlayerInventory(uint containerObjectId, uint wielderObjectId)
{
uint characterId = CharacterId;
uint container = containerObjectId != 0u ? containerObjectId : wielderObjectId;
if (characterId == 0u || container == 0u)
return false;
if (container == characterId)
return true;
return _context.Automation.Objects.TryGet(container, out PluginWorldObject pack)
&& pack.ObjectClass == PluginObjectClass.Container
&& pack.ContainerObjectId == characterId;
}
/// <summary>The number of Prismatic Tapers in the packs.</summary>
public int CountPrismaticTapers()
{
int count = 0;
foreach (PluginInventoryItem item in CaptureOwned())
{
if (string.Equals(item.Name, PrismaticTaper, StringComparison.OrdinalIgnoreCase)
&& IsPlayerInventory(item.ContainerObjectId, 0u))
{
count += item.StackSize;
}
}
return count;
}
private int Stamp(uint objectId, int hostTime)
{
if (_idStamps.TryGetValue(objectId, out var stamp) && stamp.HostTime == hostTime)
return stamp.UnixSeconds;
int unix = unchecked((int)new DateTimeOffset(_context.Clock.UtcNow).ToUnixTimeSeconds());
_idStamps[objectId] = (hostTime, unix);
return unix;
}
}