diff --git a/src/OpenAC.MosswartMassacre/FeatureCatalog.cs b/src/OpenAC.MosswartMassacre/FeatureCatalog.cs index 697ab4e..15fbffc 100644 --- a/src/OpenAC.MosswartMassacre/FeatureCatalog.cs +++ b/src/OpenAC.MosswartMassacre/FeatureCatalog.cs @@ -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 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)); } } diff --git a/src/OpenAC.MosswartMassacre/Inventory/EquipmentCantripStateFeature.cs b/src/OpenAC.MosswartMassacre/Inventory/EquipmentCantripStateFeature.cs new file mode 100644 index 0000000..d123257 --- /dev/null +++ b/src/OpenAC.MosswartMassacre/Inventory/EquipmentCantripStateFeature.cs @@ -0,0 +1,425 @@ +using AcDream.Plugin.Abstractions; +using OpenAC.MosswartMassacre.Backend; + +namespace OpenAC.MosswartMassacre.Inventory; + +/// +/// 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 equipment_cantrip_state a quarter second +/// after the last relevant change. +/// +/// +/// +/// An item is unknown until appraised, not_activatable (and left +/// out) without spells or mana, not_active 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 active +/// otherwise. The mana of an active item is projected down from the moment +/// its mana was last reported, at one point per burn interval. +/// +/// +/// 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. +/// +/// +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 _onObjectChanged; + private readonly Action _onTick; + private readonly Dictionary _lastManaUpdateByItemId = []; + private readonly Dictionary _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}"); + } + } + + /// The host has no mana-change event; a changed current-mana value is one. + 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 BuildSnapshotItems() + { + var snapshotItems = new List(); + List 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; + } + + /// + /// The character's item-cast enchantments, the ones with no end. The host + /// reports those as never running out. + /// + private List GetActiveCharacterItemSpellData() + { + var activeSpells = new List(); + 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 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 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; + } + + /// + /// 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. + /// + 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, + } +} diff --git a/src/OpenAC.MosswartMassacre/Inventory/IdentifyQueue.cs b/src/OpenAC.MosswartMassacre/Inventory/IdentifyQueue.cs new file mode 100644 index 0000000..448ec64 --- /dev/null +++ b/src/OpenAC.MosswartMassacre/Inventory/IdentifyQueue.cs @@ -0,0 +1,123 @@ +using AcDream.Plugin.Abstractions; + +namespace OpenAC.MosswartMassacre.Inventory; + +/// +/// 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 +/// while it is taken, so the queue +/// asks again on a later tick instead. +/// +/// +/// A request is finished when the host reports it answered, gave up on it, +/// or when it has been outstanding for . The owner +/// pumps the queue from the tick; nothing here runs on another thread. +/// +internal sealed class IdentifyQueue +{ + /// How long one request may stay unanswered before it is given up. + public static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10); + + private readonly Func _automation; + private readonly LinkedList _pending = new(); + private readonly HashSet _queued = []; + private uint _inFlight; + private double _inFlightSeconds; + private long _appraisalRevisionAtRequest; + + public IdentifyQueue(Func automation) + { + _automation = automation; + } + + /// Raised with the item id and whether the appraisal arrived, once per finished request. + public event Action? Finished; + + /// True when nothing is waiting and nothing is outstanding. + public bool IsIdle => _inFlight == 0u && _pending.Count == 0; + + /// Requests queued or outstanding. + public int Count => _pending.Count + (_inFlight != 0u ? 1 : 0); + + /// Queues one item unless it is already queued or outstanding. + public void Enqueue(uint objectId) + { + if (objectId == 0u || objectId == _inFlight || !_queued.Add(objectId)) + return; + _pending.AddLast(objectId); + } + + /// Drops everything, for a logoff. + public void Clear() + { + _pending.Clear(); + _queued.Clear(); + _inFlight = 0u; + _inFlightSeconds = 0d; + } + + /// The host delivered appraisal data for an object. + public void OnIdentReceived(uint objectId) + { + if (objectId != 0u && objectId == _inFlight) + Finish(success: true); + } + + /// Advances the queue; call once per tick. + 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); + } +} diff --git a/src/OpenAC.MosswartMassacre/Inventory/InventoryFeature.cs b/src/OpenAC.MosswartMassacre/Inventory/InventoryFeature.cs new file mode 100644 index 0000000..f30b222 --- /dev/null +++ b/src/OpenAC.MosswartMassacre/Inventory/InventoryFeature.cs @@ -0,0 +1,602 @@ +using AcDream.Plugin.Abstractions; +using Newtonsoft.Json; +using OpenAC.MosswartMassacre.Backend; + +namespace OpenAC.MosswartMassacre.Inventory; + +/// +/// The player's inventory on the backend: the whole inventory +/// (full_inventory) after login, at logoff and on +/// /mm sendinventory; every item entering, changing in or leaving it +/// (inventory_delta); and the Prismatic Taper count the telemetry +/// frame carries. +/// +/// +/// +/// Adds and removes go out at once. Changes to an item already tracked are +/// coalesced and sent as one update 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. +/// +/// +/// Login waits until the inventory has stopped arriving (no new owned item +/// for ) 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 ; the original asked for all of them at +/// once. +/// +/// +/// 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. +/// +/// +internal sealed class InventoryFeature : IMmFeature +{ + public const string CacheKey = "inventory.json"; + + /// How long no new owned item must arrive before login counts the inventory as complete. + public static readonly TimeSpan SettleQuiet = TimeSpan.FromSeconds(3); + + /// The longest login waits for the inventory to stop arriving. + public static readonly TimeSpan SettleLimit = TimeSpan.FromSeconds(20); + + /// The longest the login inventory waits for the socket before it is written anyway. + 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 _onObjectChanged; + private readonly Action _onTick; + private readonly Action _onHostLogoff; + + // Live tracking (the original's live inventory tracker). + private readonly HashSet _tracked = []; + private readonly HashSet _dirty = []; + private IDisposable? _flushTimer; + + // Whole-inventory uploads (the original's inventory logger). + private readonly HashSet _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"); + } + + /// + /// The Prismatic Tapers in the packs, for the telemetry frame's + /// prismatic_taper_count. Read it on the tick thread; it recounts + /// only after an item changed. + /// + public int PrismaticTaperCount + { + get + { + if (_active && _taperDirty) + { + _taperCount = _reader.CountPrismaticTapers(); + _taperDirty = false; + } + return _taperCount; + } + } + + /// True when the backend can take the login inventory. Tests replace it. + internal Func 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(); + } + + /// The inventory has arrived: start live tracking and the login upload. + private void Settle() + { + _settled = true; + foreach (PluginInventoryItem item in _reader.CaptureOwned()) + _tracked.Add(item.ObjectId); + ArmFlushTimer(); + _loginUploadPending = true; + _connectSeconds = 0d; + } + + /// The original's inventory logger at login. + 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); + } + } + + /// + /// 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. + /// + 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."); + } + + /// /mm sendinventory: appraise what needs it, wait, then send everything. + 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}"); + } + } + + /// + /// The original's inventory dump: every owned item, merged with what the + /// saved copy knew about it, saved, and sent as full_inventory. + /// + internal void DumpInventory(bool requestIdsIfMissing = false) + { + List previouslySaved = ReadCache() ?? []; + + var currentList = new List(); + 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? ReadCache() + { + IPluginStorage? scope = CacheScope(); + string? text = scope?.ReadText(CacheKey); + if (text is null) + return null; + try + { + return JsonConvert.DeserializeObject>(text) ?? []; + } + catch (JsonException) + { + _context.Chat.Write("Inventory file is corrupt."); + return []; + } + } + + private void WriteCache(List 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}"); + } + } + + /// An item of the login stream: tracked without an add, appraised if worth it. + 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); + } + + /// + /// The inventory logger's own request: an unappraised item that lands in + /// the main pack (or is wielded) is appraised once. + /// + 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); + } + + /// Two minutes plus up to three more, re-rolled for every flush. + internal int NextFlushIntervalMs() => FlushBaseMs + _rng.Next(0, FlushJitterMs + 1); + + /// Sends the current state of every item that changed since the last flush. + internal void FlushDirtyItems() + { + if (!_active) + return; + ArmFlushTimer(); + if (_dirty.Count == 0) + return; + + var ids = new List(_dirty); + _dirty.Clear(); + IReadOnlyList 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), + }); +} diff --git a/src/OpenAC.MosswartMassacre/Inventory/InventoryReader.cs b/src/OpenAC.MosswartMassacre/Inventory/InventoryReader.cs new file mode 100644 index 0000000..032c573 --- /dev/null +++ b/src/OpenAC.MosswartMassacre/Inventory/InventoryReader.cs @@ -0,0 +1,136 @@ +using AcDream.Plugin.Abstractions; + +namespace OpenAC.MosswartMassacre.Inventory; + +/// +/// Reads the player's items from the host and turns them into item records. +/// Tick thread only, like every host read. +/// +/// +/// 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. +/// +internal sealed class InventoryReader +{ + public const string PrismaticTaper = "Prismatic Taper"; + + private readonly MmContext _context; + private readonly IItemHeaderSource _headers; + private readonly Dictionary _idStamps = []; + + public InventoryReader(MmContext context, IItemHeaderSource headers) + { + _context = context; + _headers = headers; + } + + public uint CharacterId => _context.Automation.Character.ObjectId; + + /// Forgets the appraisal stamps, for a new login. + public void Reset() => _idStamps.Clear(); + + public IReadOnlyList CaptureOwned() => + _context.Automation.Items.CaptureOwnedItems(); + + public static bool TryFind(IReadOnlyList items, uint objectId, out PluginInventoryItem item) + { + foreach (PluginInventoryItem candidate in items) + { + if (candidate.ObjectId != objectId) + continue; + item = candidate; + return true; + } + item = default; + return false; + } + + /// + /// 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. + /// + public bool IsIdentified(uint objectId) => + _context.Automation.Objects.TryGet(objectId, out PluginWorldObject world) + && world.HasAppraisalData + && world.LastIdTime != 0; + + /// The item record for one owned item, as it stands now. + 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))); + } + + /// + /// The original's test for an item worth appraising: armor, clothing, + /// weapons, casters, jewelry, Aetheria and summoning essences. + /// + 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)); + + /// + /// The original's inventory test: the item sits in (or is wielded by) the + /// character, or sits in a pack the character holds. + /// + 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; + } + + /// The number of Prismatic Tapers in the packs. + 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; + } +} diff --git a/tests/OpenAC.MosswartMassacre.Tests/Inventory/DecalItemBuilderTests.cs b/tests/OpenAC.MosswartMassacre.Tests/Inventory/DecalItemBuilderTests.cs new file mode 100644 index 0000000..ba9a2cc --- /dev/null +++ b/tests/OpenAC.MosswartMassacre.Tests/Inventory/DecalItemBuilderTests.cs @@ -0,0 +1,114 @@ +using AcDream.Plugin.Abstractions; +using OpenAC.MosswartMassacre.Backend; +using OpenAC.MosswartMassacre.Inventory; +using static OpenAC.MosswartMassacre.Tests.Inventory.InventoryWorld; + +namespace OpenAC.MosswartMassacre.Tests.Inventory; + +public sealed class DecalItemBuilderTests +{ + [Fact] + public void With_the_header_flags_known_a_field_sent_as_zero_is_present_and_an_unsent_one_absent() + { + // The original's dumps hold "218103822": 0 for items whose header + // carried a zero equipable-slots field (1153 of 3058 items). + PluginInventoryItem tapers = Tapers(); + uint flags = HeaderFlags.Value | HeaderFlags.Usable | HeaderFlags.ValidLocations + | HeaderFlags.StackSize | HeaderFlags.MaxStackSize | HeaderFlags.PluralName; + + MyWorldObject record = DecalItemBuilder.Create(new ItemSource( + tapers, null, false, 0, new ItemHeaderWords { WeenieHeaderFlags = flags })); + + Assert.Equal(0, record.IntValues[DecalKeys.EquipableSlots]); + Assert.Equal(unchecked((int)flags), record.IntValues[DecalKeys.CreateFlags1]); + Assert.False(record.IntValues.ContainsKey(DecalKeys.RawBurden)); + } + + [Fact] + public void Without_the_header_flags_a_zero_field_is_left_out_and_the_value_key_reads_minus_one() + { + PluginInventoryItem bare = FakeItemsBare(0x80000555u, "Bare Thing"); + + MyWorldObject record = DecalItemBuilder.Create(new ItemSource(bare, null, false, 0, default)); + + Assert.Equal(-1, record.IntValues[DecalKeys.RawValue]); + Assert.Equal(-1, record.Value); + Assert.False(record.IntValues.ContainsKey(DecalKeys.EquipableSlots)); + Assert.False(record.IntValues.ContainsKey(DecalKeys.CreateFlags1)); + Assert.False(record.IntValues.ContainsKey(DecalKeys.PhysicsDataFlags)); + Assert.False(record.IntValues.ContainsKey(DecalKeys.Setup)); + } + + [Fact] + public void The_physics_and_second_header_words_are_written_when_known() + { + PluginInventoryItem tachi = Tachi(appraised: false); + var words = new ItemHeaderWords + { + WeenieHeaderFlags2 = 1u, + PhysicsDescriptionFlags = 0x21881u, + PhysicsWord = 17u, + SetupId = 0x02000155u, + Scale = 0d, + ParentObjectId = Me, + ParentLocation = 1, + HookType = 2, + }; + + MyWorldObject record = DecalItemBuilder.Create(new ItemSource(tachi, null, false, 0, words)); + + Assert.Equal(1, record.IntValues[DecalKeys.CreateFlags2]); + Assert.Equal(0x21881, record.IntValues[DecalKeys.PhysicsDataFlags]); + Assert.Equal(17, record.IntValues[DecalKeys.PhysicsWord]); + Assert.Equal(0x02000155, record.IntValues[DecalKeys.Setup]); + Assert.Equal(0d, record.DoubleValues[DecalKeys.Scale]); + Assert.Equal(unchecked((int)Me), record.IntValues[DecalKeys.Wielder]); + Assert.Equal(1, record.IntValues[DecalKeys.WieldingSlot]); + Assert.Equal(2, record.IntValues[DecalKeys.HookType]); + } + + [Fact] + public void Combine_keeps_an_earlier_appraisal_and_refreshes_it_from_the_live_item() + { + // MyWorldObjectCreator.cs:44-61. + MyWorldObject older = DecalItemBuilder.Create(new ItemSource( + Tachi(appraised: true), TachiProps(), true, 1700000000, default)); + PluginInventoryItem moved = Tachi(appraised: false) with { WielderObjectId = 0u, ContainerObjectId = 0x80000900u }; + MyWorldObject newer = DecalItemBuilder.Create(new ItemSource(moved, null, false, 0, default)); + + MyWorldObject combined = DecalItemBuilder.Combine(older, newer); + + Assert.Same(older, combined); + Assert.True(combined.HasIdData); + Assert.Equal(1700000000, combined.LastIdTime); + Assert.Equal([2598, 2603], combined.Spells); + Assert.Equal(50, combined.MaxDamage); + Assert.Equal(unchecked((int)0x80000900u), combined.ContainerId); + Assert.Equal(unchecked((int)0x80000900u), combined.IntValues[DecalKeys.Container]); + Assert.Equal(combined.IntValues.Keys.Order(), combined.IntValues.Keys); + } + + [Fact] + public void Combine_takes_the_live_item_when_it_is_appraised_or_the_old_one_was_not() + { + MyWorldObject older = DecalItemBuilder.Create(new ItemSource(Tapers(), null, false, 0, default)); + MyWorldObject newer = DecalItemBuilder.Create(new ItemSource(Tapers(60), null, false, 0, default)); + + Assert.Same(newer, DecalItemBuilder.Combine(older, newer)); + } + + [Fact] + public void A_saved_record_reads_back_and_writes_the_same_frame() + { + MyWorldObject record = DecalItemBuilder.Create(new ItemSource( + Breastplate(), BreastplateProps(), true, 1790325015, default)); + string json = Wire.Serialize(record); + + MyWorldObject? back = Newtonsoft.Json.JsonConvert.DeserializeObject(json); + + Assert.Equal(json, Wire.Serialize(back)); + } + + private static PluginInventoryItem FakeItemsBare(uint id, string name) => + Fakes.FakeItems.Item(id, name) with { ObjectClass = PluginObjectClass.Misc, MaximumStackSize = 1 }; +} diff --git a/tests/OpenAC.MosswartMassacre.Tests/Inventory/EquipmentCantripStateTests.cs b/tests/OpenAC.MosswartMassacre.Tests/Inventory/EquipmentCantripStateTests.cs new file mode 100644 index 0000000..1ed51a2 --- /dev/null +++ b/tests/OpenAC.MosswartMassacre.Tests/Inventory/EquipmentCantripStateTests.cs @@ -0,0 +1,95 @@ +using AcDream.Plugin.Abstractions; +using Newtonsoft.Json.Linq; +using OpenAC.MosswartMassacre.Inventory; +using static OpenAC.MosswartMassacre.Tests.Inventory.InventoryWorld; + +namespace OpenAC.MosswartMassacre.Tests.Inventory; + +public sealed class EquipmentCantripStateTests +{ + private static PluginHarness Arrange(params PluginActiveEnchantment[] enchantments) => + new("Mossy", arrange: host => + { + Own(host, Tachi(appraised: true), TachiProps(), hostIdTime: 5000); + host.Automation.Spells.All = + [ + Spell(2598, family: 100, difficulty: 150), + Spell(2603, family: 101, difficulty: 150), + Spell(5183, family: 200, difficulty: 400), + Spell(6089, family: 100, difficulty: 400), + Spell(6094, family: 101, difficulty: 400), + ]; + host.Automation.Character.ActiveEnchantments = enchantments; + }); + + private static JToken OnlyItem(PluginHarness harness) => + JObject.Parse(harness.FramesOfTypeRaw("equipment_cantrip_state").Last())["items"]!.Single(); + + [Fact] + public void A_timed_buff_does_not_keep_an_item_spell_running_only_an_item_cast_one_does() + { + using var harness = Arrange( + new PluginActiveEnchantment(6089, 100, 8, double.PositiveInfinity), + new PluginActiveEnchantment(6094, 101, 8, 1800d)); + harness.Login(); + harness.Feature().SendSnapshot(); + + JToken item = OnlyItem(harness); + Assert.Equal("not_active", (string)item["state"]!); + Assert.Equal(1500, (int)item["current_mana"]!); + Assert.Equal(JTokenType.Null, item["mana_time_remaining_seconds"]!.Type); + } + + [Fact] + public void An_item_without_mana_is_not_active_and_one_without_spells_is_left_out() + { + using var harness = Arrange(); + PluginItemProperties empty = TachiProps(); + var ints = new Dictionary(empty.Ints) { [107] = 0 }; + Own(harness.Host, Tachi(appraised: true), empty with { Ints = ints }, hostIdTime: 5000); + harness.Login(); + harness.Feature().SendSnapshot(); + Assert.Equal("not_active", (string)OnlyItem(harness)["state"]!); + + Own(harness.Host, Tachi(appraised: false) with { AppraisedSpellIds = [] }, TachiProps(), hostIdTime: 5000); + harness.Feature().SendSnapshot(); + Assert.Empty(JObject.Parse(harness.FramesOfTypeRaw("equipment_cantrip_state").Last())["items"]!); + } + + [Fact] + public void Changes_within_a_quarter_second_send_one_frame() + { + using var harness = Arrange(); + harness.Login(); + harness.Tick(0.3); + harness.Frames.Clear(); + + Raise(harness.Host, 0x80000202u, PluginObjectChangeKind.Updated); + harness.Tick(0.1); + Raise(harness.Host, 0x80000202u, PluginObjectChangeKind.Updated); + harness.Tick(0.2); + Assert.Empty(harness.FramesOfTypeRaw("equipment_cantrip_state")); + harness.Tick(0.1); + + Assert.Single(harness.FramesOfTypeRaw("equipment_cantrip_state")); + } + + [Fact] + public void A_change_in_the_characters_enchantments_sends_a_frame() + { + using var harness = Arrange(); + harness.Login(); + harness.Tick(0.3); + harness.Frames.Clear(); + + harness.Tick(1.0); + Assert.Empty(harness.FramesOfTypeRaw("equipment_cantrip_state")); + + harness.Host.Automation.Character.ActiveEnchantments = + [new PluginActiveEnchantment(6089, 100, 8, double.PositiveInfinity)]; + harness.Tick(1.0); + harness.Tick(0.3); + + Assert.Single(harness.FramesOfTypeRaw("equipment_cantrip_state")); + } +} diff --git a/tests/OpenAC.MosswartMassacre.Tests/Inventory/IdentifyQueueTests.cs b/tests/OpenAC.MosswartMassacre.Tests/Inventory/IdentifyQueueTests.cs new file mode 100644 index 0000000..c09a4eb --- /dev/null +++ b/tests/OpenAC.MosswartMassacre.Tests/Inventory/IdentifyQueueTests.cs @@ -0,0 +1,88 @@ +using AcDream.Plugin.Abstractions; +using OpenAC.MosswartMassacre.Inventory; +using OpenAC.MosswartMassacre.Tests.Fakes; + +namespace OpenAC.MosswartMassacre.Tests.Inventory; + +public sealed class IdentifyQueueTests +{ + private static (IdentifyQueue Queue, FakeAutomation Automation, List<(uint, bool)> Finished) Create() + { + var automation = new FakeAutomation { IsAvailable = true }; + var queue = new IdentifyQueue(() => automation); + var finished = new List<(uint, bool)>(); + queue.Finished += (id, ok) => finished.Add((id, ok)); + return (queue, automation, finished); + } + + [Fact] + public void Requests_go_out_one_at_a_time_and_the_next_waits_for_the_answer() + { + var (queue, automation, finished) = Create(); + queue.Enqueue(1); + queue.Enqueue(2); + queue.Enqueue(1); + + queue.Pump(0.015); + queue.Pump(0.015); + Assert.Equal([1u], automation.Objects.IdentifyRequests); + + queue.OnIdentReceived(1); + queue.Pump(0.015); + Assert.Equal([1u, 2u], automation.Objects.IdentifyRequests); + Assert.Equal([(1u, true)], finished); + Assert.False(queue.IsIdle); + + queue.OnIdentReceived(2); + Assert.True(queue.IsIdle); + } + + [Fact] + public void A_busy_slot_is_asked_again_on_a_later_tick() + { + var (queue, automation, _) = Create(); + automation.Objects.IdentifyBusyForCalls[7] = 2; + queue.Enqueue(7); + + queue.Pump(0.015); + queue.Pump(0.015); + queue.Pump(0.015); + + Assert.Equal([7u, 7u, 7u], automation.Objects.IdentifyRequests); + Assert.Equal(1, queue.Count); + } + + [Fact] + public void The_host_answer_or_abandon_finishes_a_request() + { + var (queue, automation, finished) = Create(); + queue.Enqueue(3); + queue.Enqueue(4); + queue.Pump(0.015); + + automation.Loot.Appraisal = new PluginAppraisalState(5, 0u, 3u); + queue.Pump(0.015); + automation.Loot.Appraisal = new PluginAppraisalState(6, 0u, 3u, LastAbandonedObjectId: 4u); + queue.Pump(0.015); + + Assert.Equal([(3u, true), (4u, false)], finished); + } + + [Fact] + public void An_unanswered_request_is_given_up_after_the_timeout_and_a_refusal_at_once() + { + var (queue, automation, finished) = Create(); + automation.Objects.IdentifyResultOverrides[8] = PluginItemCommandStatus.InvalidItem; + queue.Enqueue(8); + queue.Enqueue(9); + + queue.Pump(0.015); + Assert.Equal([(8u, false)], finished); + + for (int i = 0; i < 700; i++) + queue.Pump(0.015); + + Assert.Equal([(8u, false), (9u, false)], finished); + Assert.True(queue.IsIdle); + } +} diff --git a/tests/OpenAC.MosswartMassacre.Tests/Inventory/InventoryFeatureTests.cs b/tests/OpenAC.MosswartMassacre.Tests/Inventory/InventoryFeatureTests.cs new file mode 100644 index 0000000..e283ed5 --- /dev/null +++ b/tests/OpenAC.MosswartMassacre.Tests/Inventory/InventoryFeatureTests.cs @@ -0,0 +1,255 @@ +using AcDream.Plugin.Abstractions; +using Newtonsoft.Json.Linq; +using OpenAC.MosswartMassacre.Inventory; +using static OpenAC.MosswartMassacre.Tests.Inventory.InventoryWorld; + +namespace OpenAC.MosswartMassacre.Tests.Inventory; + +public sealed class InventoryFeatureTests +{ + private const string Prefix = "[Mosswart Massacre] "; + + [Fact] + public void First_login_appraises_what_needs_it_and_sends_only_when_the_answers_are_in() + { + using var harness = new PluginHarness("Mossy", arrange: host => + { + Own(host, Tachi(appraised: false)); + Own(host, Tapers()); + }); + LoginAndSettle(harness); + harness.Tick(0.1); + + Assert.Contains(Prefix + "Requesting id information for all armor/weapon inventory. This will take a few minutes...", harness.ChatLines); + Assert.Equal([0x80000202u], harness.Host.Automation.Objects.IdentifyRequests); + harness.Tick(0.1); + Assert.Empty(harness.FramesOfTypeRaw("full_inventory")); + + Own(harness.Host, Tachi(appraised: true), TachiProps(), hostIdTime: 5000); + Raise(harness.Host, 0x80000202u, PluginObjectChangeKind.IdentReceived); + harness.Tick(0.1); + + JObject frame = JObject.Parse(harness.FramesOfTypeRaw("full_inventory").Single()); + Assert.Equal(2, (int)frame["item_count"]!); + Assert.True((bool)frame["items"]![0]!["HasIdData"]!); + Assert.Contains(Prefix + "Requesting id information for all armor/weapon inventory completed. Log file written.", harness.ChatLines); + Assert.Contains(Prefix + "Inventory sent to MosswartOverlord", harness.ChatLines); + Assert.NotNull(harness.Host.Storage.ReadText(InventoryFeature.CacheKey)); + } + + [Fact] + public void A_later_login_sends_at_once_and_keeps_the_saved_appraisal_of_an_unappraised_item() + { + using var harness = new PluginHarness("Mossy", arrange: host => + Own(host, Tachi(appraised: true), TachiProps(), hostIdTime: 5000)); + LoginAndSettle(harness); + harness.Tick(0.1); + harness.Tick(0.1); + Assert.Single(harness.FramesOfTypeRaw("full_inventory")); + + // Relog: the session's appraisal is gone, the saved copy is not. + harness.Plugin.OnLogoff(); + Own(harness.Host, Tachi(appraised: false)); + harness.Frames.Clear(); + harness.Host.Automation.Objects.IdentifyRequests.Clear(); + LoginAndSettle(harness); + harness.Tick(0.1); + + JObject frame = JObject.Parse(harness.FramesOfTypeRaw("full_inventory").Single()); + JToken item = frame["items"]![0]!; + Assert.True((bool)item["HasIdData"]!); + Assert.Equal(50, (int)item["MaxDamage"]!); + Assert.Equal("Light Weapons", (string)item["EquipSkill"]!); + // Saved data present, so the original did not ask again from the dump + // (MossyInventory.cs:253); the login stream already asked once. + Assert.Equal([0x80000202u], harness.Host.Automation.Objects.IdentifyRequests); + } + + [Fact] + public void Sendinventory_sends_at_once_when_everything_is_appraised() + { + using var harness = new PluginHarness("Mossy", arrange: host => Own(host, Tapers())); + LoginAndSettle(harness); + harness.Tick(0.1); + harness.Tick(0.1); + harness.Frames.Clear(); + + harness.Context.Commands.Dispatch("sendinventory"); + + Assert.Single(harness.FramesOfTypeRaw("full_inventory")); + Assert.Contains(Prefix + "[INV] All 1 items already identified. Sending...", harness.ChatLines); + Assert.Contains(Prefix + "[INV] Inventory upload completed", harness.ChatLines); + } + + [Fact] + public void Sendinventory_waits_for_appraisals_and_refuses_when_streaming_is_off() + { + using var harness = new PluginHarness("Mossy", arrange: host => Own(host, Tapers())); + LoginAndSettle(harness); + harness.Tick(0.1); + harness.Tick(0.1); + harness.Frames.Clear(); + Own(harness.Host, Ring()); + + harness.Context.Commands.Dispatch("sendinventory"); + Assert.Contains(Prefix + "[INV] Scanning 2 items, requesting ID for 1. Waiting for identification...", harness.ChatLines); + harness.Tick(0.1); + Assert.Empty(harness.FramesOfTypeRaw("full_inventory")); + + Own(harness.Host, Ring(), Props(ints: new() { [107] = 10, [108] = 20 }), hostIdTime: 42); + Raise(harness.Host, 0x80000404u, PluginObjectChangeKind.IdentReceived); + harness.Tick(0.1); + Assert.Single(harness.FramesOfTypeRaw("full_inventory")); + Assert.Contains(Prefix + "[INV] All items identified. Inventory upload completed.", harness.ChatLines); + + harness.Context.Settings.Current.WebSocketEnabled = false; + harness.Context.Commands.Dispatch("sendinventory"); + Assert.Contains(Prefix + "[INV] WebSocket streaming is disabled", harness.ChatLines); + } + + [Fact] + public void The_login_stream_is_tracked_without_adds_and_items_after_it_are_added() + { + using var harness = new PluginHarness("Mossy"); + harness.Login(); + InventoryFeature feature = harness.Feature(); + feature.IsBackendReady = () => true; + + Own(harness.Host, Tapers()); + Raise(harness.Host, 0x80000101u, PluginObjectChangeKind.Created); + Assert.False(feature.IsSettled); + for (int i = 0; i < 40 && !feature.IsSettled; i++) + harness.Tick(0.1); + + Assert.Empty(harness.FramesOfTypeRaw("inventory_delta")); + Assert.Equal(1, feature.TrackedCount); + + Own(harness.Host, Breastplate(), BreastplateProps(), hostIdTime: 9); + Raise(harness.Host, 0x80000303u, PluginObjectChangeKind.Created); + Assert.Equal("add", (string)JObject.Parse(harness.FramesOfTypeRaw("inventory_delta").Single())["action"]!); + } + + [Fact] + public void Changes_are_held_until_the_jittered_flush_between_two_and_five_minutes() + { + using var harness = new PluginHarness("Mossy", arrange: host => Own(host, Tapers())); + InventoryFeature feature = LoginAndSettle(harness); + harness.Frames.Clear(); + + Own(harness.Host, Tapers(60)); + Raise(harness.Host, 0x80000101u, PluginObjectChangeKind.Updated); + Raise(harness.Host, 0x80000101u, PluginObjectChangeKind.Updated); + Assert.Equal(1, feature.DirtyCount); + + for (int i = 0; i < 1190; i++) + harness.Tick(0.1); + Assert.Empty(harness.FramesOfTypeRaw("inventory_delta")); + + for (int i = 0; i < 1820 && harness.FramesOfTypeRaw("inventory_delta").Count == 0; i++) + harness.Tick(0.1); + + JObject update = JObject.Parse(harness.FramesOfTypeRaw("inventory_delta").Single()); + Assert.Equal("update", (string)update["action"]!); + Assert.Equal(60, (int)update["item"]!["IntValues"]!["218103814"]!); + } + + [Fact] + public void The_flush_interval_is_two_minutes_plus_up_to_three() + { + using var harness = new PluginHarness("Mossy"); + InventoryFeature feature = harness.Feature(); + + int[] samples = Enumerable.Range(0, 2000).Select(_ => feature.NextFlushIntervalMs()).ToArray(); + + Assert.All(samples, ms => Assert.InRange(ms, 120000, 300000)); + Assert.True(samples.Max() - samples.Min() > 150000); + } + + [Fact] + public void An_item_moved_out_of_the_inventory_is_removed_and_one_in_a_side_pack_is_added() + { + const uint pack = 0x80000900u; + using var harness = new PluginHarness("Mossy", arrange: host => + { + Own(host, Tapers()); + host.Automation.Objects.Replace(new PluginWorldObject(pack, 1u, "Pack", PluginObjectClass.Container, 0x200u, Me, 0u)); + }); + LoginAndSettle(harness); + harness.Frames.Clear(); + + Own(harness.Host, Ring() with { ContainerObjectId = pack, WielderObjectId = 0u, EquippedLocation = 0u }); + Raise(harness.Host, 0x80000404u, PluginObjectChangeKind.Moved); + Own(harness.Host, Tapers() with { ContainerObjectId = 0x7000AAAAu }); + Raise(harness.Host, 0x80000101u, PluginObjectChangeKind.Moved); + + string[] actions = harness.FramesOfTypeRaw("inventory_delta") + .Select(f => (string)JObject.Parse(f)["action"]!).ToArray(); + Assert.Equal(["add", "remove"], actions); + } + + [Fact] + public void A_relog_starts_tracking_afresh() + { + using var harness = new PluginHarness("Mossy", arrange: host => Own(host, Tapers())); + InventoryFeature feature = LoginAndSettle(harness); + Own(harness.Host, Tapers(60)); + Raise(harness.Host, 0x80000101u, PluginObjectChangeKind.Updated); + Assert.Equal(1, feature.DirtyCount); + + harness.Plugin.OnLogoff(); + Assert.Equal(0, feature.DirtyCount); + Assert.Equal(0, feature.TrackedCount); + + harness.Host.Automation.Character.Name = "Horan"; + harness.Frames.Clear(); + LoginAndSettle(harness); + Raise(harness.Host, 0x80000101u, PluginObjectChangeKind.Updated); + + Assert.Empty(harness.FramesOfTypeRaw("inventory_delta")); + Assert.Equal(1, feature.DirtyCount); + } + + [Fact] + public void Logoff_sends_the_inventory_while_the_socket_is_still_up() + { + using var harness = new PluginHarness("Mossy", arrange: host => Own(host, Tapers())); + LoginAndSettle(harness); + harness.Tick(0.1); + harness.Tick(0.1); + harness.Frames.Clear(); + + harness.Host.Events.RaiseLogoff(); + + Assert.Single(harness.FramesOfTypeRaw("full_inventory")); + } + + [Fact] + public void The_taper_count_follows_the_stacks_in_the_packs() + { + using var harness = new PluginHarness("Mossy", arrange: host => Own(host, Tapers())); + InventoryFeature feature = LoginAndSettle(harness); + Assert.Equal(57, feature.PrismaticTaperCount); + + Own(harness.Host, Tapers(12)); + Raise(harness.Host, 0x80000101u, PluginObjectChangeKind.Updated); + Assert.Equal(12, feature.PrismaticTaperCount); + + Forget(harness.Host, 0x80000101u); + harness.Host.Events.RaiseObjectChanged(new PluginObjectChange(0x80000101u, PluginObjectChangeKind.Released)); + Assert.Equal(0, feature.PrismaticTaperCount); + } + + [Fact] + public void Inventory_log_off_sends_no_login_inventory() + { + using var harness = new PluginHarness("Mossy", arrange: host => Own(host, Tapers())); + harness.Login(); + harness.Context.Settings.Current.InventoryLog = false; + InventoryFeature feature = harness.Feature(); + feature.IsBackendReady = () => true; + for (int i = 0; i < 40; i++) + harness.Tick(0.1); + + Assert.Empty(harness.FramesOfTypeRaw("full_inventory")); + } +} diff --git a/tests/OpenAC.MosswartMassacre.Tests/Inventory/InventoryGoldenTests.cs b/tests/OpenAC.MosswartMassacre.Tests/Inventory/InventoryGoldenTests.cs new file mode 100644 index 0000000..263fffc --- /dev/null +++ b/tests/OpenAC.MosswartMassacre.Tests/Inventory/InventoryGoldenTests.cs @@ -0,0 +1,155 @@ +using AcDream.Plugin.Abstractions; +using OpenAC.MosswartMassacre.Inventory; +using static OpenAC.MosswartMassacre.Tests.Inventory.InventoryWorld; + +namespace OpenAC.MosswartMassacre.Tests.Inventory; + +/// +/// Byte-for-byte frames, derived by hand from the original plugin's code. +/// Shape sources (original plugin, head 48cf13b): +/// +/// Item record: Shared/MyWorldObject.cs:16-29 (fields, in order) and +/// :118-315 (getters, in order; doubles stay doubles so -1 is written -1.0). +/// Record contents: Shared/MyWorldObjectCreator.cs:20-39 — the framework's +/// key tables, spells, active spells, id, appraisal time, class, icon, container. +/// Which framework keys exist and what they hold: the original's own item +/// dumps (3058 items), see . +/// Envelopes: MosswartMassacre/WebSocket.cs:339-380 (full_inventory, +/// inventory_delta add/update and remove) and :411-423 (equipment_cantrip_state); +/// the per-item mana entry EquipmentCantripStateTracker.cs:222-229. +/// +/// +public sealed class InventoryGoldenTests +{ + // The harness clock, 2026-09-25 08:30:15.123 UTC, in Unix seconds: the + // original stamped an appraisal with the wall clock when it arrived. + private const string IdStamp = "1790325015"; + + // Getters from ElementalDmgBonus onward for an item with none of their keys + // except Value/Burden (MyWorldObject.cs:163-315). + private const string TaperRecord = + """{"HasIdData":false,"Id":-2147483391,"LastIdTime":0,"ObjectClass":12,"Icon":11658,"ContainerId":1342177281,"BoolValues":{},"DoubleValues":{},"IntValues":{"5":57,"19":570,"218103808":20631,"218103809":11658,"218103810":1342177281,"218103814":57,"218103815":100,"218103834":4096,"218103835":18,"218103843":1},"StringValues":{"1":"Prismatic Taper","184549376":"Prismatic Tapers"},"ActiveSpells":[],"Spells":[],"Material":null,"Name":"Prismatic Taper","EquipSkill":null,"Mastery":null,"ItemSet":null,"ArmorLevel":-1,"Imbue":null,"Tinks":-1,"MaxDamage":-1,"ElementalDmgBonus":-1,"Variance":-1.0,"DamageBonus":-1.0,"ElementalDamageVersusMonsters":-1.0,"AttackBonus":-1.0,"MeleeDefenseBonus":-1.0,"MagicDBonus":-1.0,"MissileDBonus":-1.0,"ManaCBonus":-1.0,"WieldLevel":-1,"SkillLevel":-1,"LoreRequirement":-1,"SalvageWorkmanship":-1.0,"Workmanship":-1,"Value":570,"Burden":57,"DamRating":-1,"DamResistRating":-1,"CritRating":-1,"CritResistRating":-1,"CritDamRating":-1,"CritDamResistRating":-1,"HealBoostRating":-1,"VitalityRating":-1,"TotalRating":-1,"CalcedStartingArmorLevel":-1,"CalcedBuffedTinkedDoT":-1.0,"CalcedBuffedMissileDamage":-1.0,"BuffedElementalDamageVersusMonsters":-1.0,"BuffedAttackBonus":-1.0,"BuffedMeleeDefenseBonus":-1.0,"BuffedManaCBonus":-1.0}"""; + + // Armor profile -> 167772160..166 (slash, pierce, bludgeon, acid, lightning, fire, + // cold), floats widened; spell book split on the 0x80000000 bit; ItemSet 5 from + // Dictionaries.AttributeSetInfo; CalcedStartingArmorLevel (MyWorldObject.cs:232-265): + // 600 - 200 (no material) - (240 active Incantation of Impenetrability - 20 Minor + // Impenetrability bonus) = 180. + private const string BreastplateRecord = + """{"HasIdData":true,"Id":-2147482877,"LastIdTime":""" + IdStamp + ""","ObjectClass":2,"Icon":4660,"ContainerId":1342177281,"BoolValues":{"69":true},"DoubleValues":{"167772160":1.2000000476837158,"167772161":1.0,"167772162":0.800000011920929,"167772163":0.20000000298023224,"167772164":1.5,"167772165":0.4000000059604645,"167772166":0.6000000238418579},"IntValues":{"5":800,"19":5000,"28":600,"265":5,"218103808":6044,"218103809":4660,"218103810":1342177281,"218103821":1024,"218103822":512,"218103824":1,"218103834":2,"218103835":18,"218103838":2,"218103843":1,"218103848":2},"StringValues":{"1":"Celdon Breastplate"},"ActiveSpells":[4407,1483],"Spells":[1486,2604],"Material":null,"Name":"Celdon Breastplate","EquipSkill":null,"Mastery":null,"ItemSet":"Noble Relic Set","ArmorLevel":600,"Imbue":null,"Tinks":-1,"MaxDamage":-1,"ElementalDmgBonus":-1,"Variance":-1.0,"DamageBonus":-1.0,"ElementalDamageVersusMonsters":-1.0,"AttackBonus":-1.0,"MeleeDefenseBonus":-1.0,"MagicDBonus":-1.0,"MissileDBonus":-1.0,"ManaCBonus":-1.0,"WieldLevel":-1,"SkillLevel":-1,"LoreRequirement":-1,"SalvageWorkmanship":-1.0,"Workmanship":-1,"Value":5000,"Burden":800,"DamRating":-1,"DamResistRating":-1,"CritRating":-1,"CritResistRating":-1,"CritDamRating":-1,"CritDamResistRating":-1,"HealBoostRating":-1,"VitalityRating":-1,"TotalRating":-1,"CalcedStartingArmorLevel":180,"CalcedBuffedTinkedDoT":-1.0,"CalcedBuffedMissileDamage":-1.0,"BuffedElementalDamageVersusMonsters":-1.0,"BuffedAttackBonus":-1.0,"BuffedMeleeDefenseBonus":-1.0,"BuffedManaCBonus":-1.0}"""; + + // Wielded: the container key and ContainerId are the wielder. The second header + // (public flags 0x04000000) brings the underlay key, here 0. + private const string TachiUnappraisedRecord = + """{"HasIdData":false,"Id":-2147483134,"LastIdTime":0,"ObjectClass":1,"Icon":13162,"ContainerId":1342177281,"BoolValues":{},"DoubleValues":{"167772169":8.0},"IntValues":{"5":270,"10":1048576,"19":12000,"131":64,"218103808":30310,"218103809":13162,"218103810":1342177281,"218103822":1048576,"218103823":1,"218103824":1,"218103826":16,"218103834":1,"218103835":67108882,"218103843":6291460,"218103849":6958,"218103850":0},"StringValues":{"1":"Fire Tachi"},"ActiveSpells":[],"Spells":[],"Material":"Steel","Name":"Fire Tachi","EquipSkill":null,"Mastery":null,"ItemSet":null,"ArmorLevel":-1,"Imbue":null,"Tinks":-1,"MaxDamage":-1,"ElementalDmgBonus":-1,"Variance":-1.0,"DamageBonus":-1.0,"ElementalDamageVersusMonsters":-1.0,"AttackBonus":-1.0,"MeleeDefenseBonus":-1.0,"MagicDBonus":-1.0,"MissileDBonus":-1.0,"ManaCBonus":-1.0,"WieldLevel":-1,"SkillLevel":-1,"LoreRequirement":-1,"SalvageWorkmanship":8.0,"Workmanship":-1,"Value":12000,"Burden":270,"DamRating":-1,"DamResistRating":-1,"CritRating":-1,"CritResistRating":-1,"CritDamRating":-1,"CritDamResistRating":-1,"HealBoostRating":-1,"VitalityRating":-1,"TotalRating":-1,"CalcedStartingArmorLevel":-1,"CalcedBuffedTinkedDoT":-1.0,"CalcedBuffedMissileDamage":-1.0,"BuffedElementalDamageVersusMonsters":-1.0,"BuffedAttackBonus":-1.0,"BuffedMeleeDefenseBonus":-1.0,"BuffedManaCBonus":-1.0}"""; + + // Weapon profile -> 218103839..842 and 167772171..174. Getters: EquipSkill 45 -> + // "Light Weapons", Mastery 2 -> "Sword", Imbue 1 -> "CS", SkillLevel (158 != 7) 370, + // TotalRating 2. CalcedBuffedTinkedDoT (MyWorldObject.cs:270-302): max damage + // 50 - 24 (active Incantation of Blood Drinker) + 2 (Minor Blood Thirst) = 28, seven + // tinks left, giving 55.431200000000004. BuffedAttackBonus: 1.2000000476837158 + .03 + // (Minor Heart Thirst) = 1.2300000476837158. + private const string TachiAppraisedRecord = + """{"HasIdData":true,"Id":-2147483134,"LastIdTime":""" + IdStamp + ""","ObjectClass":1,"Icon":13162,"ContainerId":1342177281,"BoolValues":{},"DoubleValues":{"5":-0.05,"29":1.15,"167772169":8.0,"167772171":0.5,"167772172":1.2000000476837158,"167772173":1.0,"167772174":1.0},"IntValues":{"5":270,"10":1048576,"19":12000,"105":8,"107":1500,"108":2000,"109":300,"131":64,"158":2,"159":45,"160":370,"171":3,"179":1,"353":2,"370":2,"218103808":30310,"218103809":13162,"218103810":1342177281,"218103822":1048576,"218103823":1,"218103824":1,"218103826":16,"218103834":1,"218103835":67108882,"218103838":2,"218103839":30,"218103840":45,"218103841":16,"218103842":50,"218103843":6291460,"218103848":1,"218103849":6958,"218103850":0},"StringValues":{"1":"Fire Tachi","16":"Fire Tachi of Blood Drinker"},"ActiveSpells":[5183],"Spells":[2598,2603],"Material":"Steel","Name":"Fire Tachi","EquipSkill":"Light Weapons","Mastery":"Sword","ItemSet":null,"ArmorLevel":-1,"Imbue":"CS","Tinks":3,"MaxDamage":50,"ElementalDmgBonus":-1,"Variance":0.5,"DamageBonus":1.0,"ElementalDamageVersusMonsters":-1.0,"AttackBonus":1.2000000476837158,"MeleeDefenseBonus":1.15,"MagicDBonus":-1.0,"MissileDBonus":-1.0,"ManaCBonus":-1.0,"WieldLevel":-1,"SkillLevel":370,"LoreRequirement":300,"SalvageWorkmanship":8.0,"Workmanship":8,"Value":12000,"Burden":270,"DamRating":2,"DamResistRating":-1,"CritRating":-1,"CritResistRating":-1,"CritDamRating":-1,"CritDamResistRating":-1,"HealBoostRating":-1,"VitalityRating":-1,"TotalRating":2,"CalcedStartingArmorLevel":-1,"CalcedBuffedTinkedDoT":55.431200000000004,"CalcedBuffedMissileDamage":-1.0,"BuffedElementalDamageVersusMonsters":-1.0,"BuffedAttackBonus":1.2300000476837158,"BuffedMeleeDefenseBonus":1.15,"BuffedManaCBonus":-1.0}"""; + + [Fact] + public void Full_inventory_frame_after_the_first_login() + { + using var harness = new PluginHarness("Mossy", arrange: host => + { + Own(host, Tapers()); + Own(host, Breastplate(), BreastplateProps(), hostIdTime: 777); + }); + + LoginAndSettle(harness); + harness.Tick(0.1); // the login upload: no saved copy, so appraise and wait + harness.Tick(0.1); // nothing needed appraising: send + + // WebSocket.cs:341-348: type, timestamp, character_name, item_count, items. + string expected = + """{"type":"full_inventory","timestamp":"2026-09-25T08:30:15.1230000Z","character_name":"Mossy","item_count":2,"items":[""" + + TaperRecord + "," + BreastplateRecord + "]}"; + Assert.Equal([expected], harness.FramesOfTypeRaw("full_inventory")); + } + + [Fact] + public void Inventory_delta_add_then_update_after_appraisal() + { + using var harness = new PluginHarness("Mossy"); + InventoryFeature feature = LoginAndSettle(harness); + harness.Tick(0.1); + harness.Tick(0.1); + harness.Frames.Clear(); + + Own(harness.Host, Tachi(appraised: false)); + Raise(harness.Host, 0x80000202u, PluginObjectChangeKind.Created); + + // WebSocket.cs:356-363: type, timestamp, character_name, action, item. + Assert.Equal( + ["""{"type":"inventory_delta","timestamp":"2026-09-25T08:30:15.1230000Z","character_name":"Mossy","action":"add","item":""" + TachiUnappraisedRecord + "}"], + harness.FramesOfTypeRaw("inventory_delta")); + + harness.Tick(0.015); + Assert.Contains(0x80000202u, harness.Host.Automation.Objects.IdentifyRequests); + + Own(harness.Host, Tachi(appraised: true), TachiProps(), hostIdTime: 5000); + Raise(harness.Host, 0x80000202u, PluginObjectChangeKind.IdentReceived); + harness.Frames.Clear(); + feature.FlushDirtyItems(); + + Assert.Equal( + ["""{"type":"inventory_delta","timestamp":"2026-09-25T08:30:15.1230000Z","character_name":"Mossy","action":"update","item":""" + TachiAppraisedRecord + "}"], + harness.FramesOfTypeRaw("inventory_delta")); + } + + [Fact] + public void Inventory_delta_remove_carries_only_the_id() + { + using var harness = new PluginHarness("Mossy", arrange: host => Own(host, Tapers())); + LoginAndSettle(harness); + harness.Frames.Clear(); + + Forget(harness.Host, 0x80000101u); + harness.Host.Events.RaiseObjectChanged(new PluginObjectChange(0x80000101u, PluginObjectChangeKind.Released)); + + // WebSocket.cs:370-377: the id is the framework's signed 32-bit id. + Assert.Equal( + ["""{"type":"inventory_delta","timestamp":"2026-09-25T08:30:15.1230000Z","character_name":"Mossy","action":"remove","item_id":-2147483391}"""], + harness.FramesOfTypeRaw("inventory_delta")); + } + + [Fact] + public void Equipment_cantrip_state_for_an_active_weapon_and_an_unappraised_ring() + { + using var harness = new PluginHarness("Mossy", arrange: host => + { + Own(host, Tachi(appraised: true), TachiProps(), hostIdTime: 5000); + Own(host, Ring()); + host.Automation.Spells.All = + [ + Spell(2598, family: 100, difficulty: 150), + Spell(2603, family: 101, difficulty: 150), + Spell(5183, family: 200, difficulty: 400), + Spell(6089, family: 100, difficulty: 400), + Spell(6094, family: 101, difficulty: 400), + ]; + host.Automation.Character.ActiveEnchantments = + [ + new PluginActiveEnchantment(6089, 100, 8, double.PositiveInfinity), + new PluginActiveEnchantment(6094, 101, 8, double.PositiveInfinity), + ]; + }); + harness.Login(); + harness.Frames.Clear(); + + // Mana was last reported at login; 65 s later three points have burned at + // one per ceil(-0.2 / -0.05) * 5 = 20 s (EquipmentCantripStateTracker.cs:296-345). + harness.Clock.Advance(TimeSpan.FromSeconds(65)); + harness.Feature().SendSnapshot(); + + // WebSocket.cs:413-419 envelope; items from EquipmentCantripStateTracker.cs:222-229. + Assert.Equal( + ["""{"type":"equipment_cantrip_state","timestamp":"2026-09-25T08:31:20.1230000Z","character_name":"Mossy","items":[{"item_id":-2147483134,"state":"active","current_mana":1497,"max_mana":2000,"mana_time_remaining_seconds":29940.0},{"item_id":-2147482620,"state":"unknown","current_mana":null,"max_mana":null,"mana_time_remaining_seconds":null}]}"""], + harness.FramesOfTypeRaw("equipment_cantrip_state")); + } +} diff --git a/tests/OpenAC.MosswartMassacre.Tests/Inventory/InventoryWorld.cs b/tests/OpenAC.MosswartMassacre.Tests/Inventory/InventoryWorld.cs new file mode 100644 index 0000000..8cf4265 --- /dev/null +++ b/tests/OpenAC.MosswartMassacre.Tests/Inventory/InventoryWorld.cs @@ -0,0 +1,205 @@ +using AcDream.Plugin.Abstractions; +using Newtonsoft.Json.Linq; +using OpenAC.MosswartMassacre.Inventory; +using OpenAC.MosswartMassacre.Tests.Fakes; + +namespace OpenAC.MosswartMassacre.Tests.Inventory; + +/// +/// Puts items into the fake host the way the real one holds them: the item +/// in the owned list, its world object in the object table, its property +/// tables when it has any. +/// +internal static class InventoryWorld +{ + /// The harness character's id. + public const uint Me = 0x50000001; + + public static PluginItemProperties Props( + Dictionary? ints = null, + Dictionary? floats = null, + Dictionary? bools = null, + Dictionary? strings = null, + PluginWeaponProfile? weapon = null, + PluginArmorProfile? armor = null) + => new( + ints ?? [], + new Dictionary(), + bools ?? [], + floats ?? [], + strings ?? [], + new Dictionary(), + new Dictionary()) + { + WeaponProfile = weapon, + ArmorProfile = armor, + }; + + /// + /// Adds or replaces an owned item. non-zero + /// with properties means appraised. + /// + public static void Own( + FakeHost host, + PluginInventoryItem item, + PluginItemProperties? properties = null, + int hostIdTime = 0) + { + List owned = host.Automation.Items.Owned; + int index = owned.FindIndex(existing => existing.ObjectId == item.ObjectId); + if (index >= 0) + owned[index] = item; + else + owned.Add(item); + + if (properties is { } props) + host.Automation.Items.Properties[item.ObjectId] = props; + else + host.Automation.Items.Properties.Remove(item.ObjectId); + + host.Automation.Objects.Replace(World(item, properties is not null, hostIdTime)); + } + + /// Takes an item out of the owned list and the object table. + public static void Forget(FakeHost host, uint objectId) + { + host.Automation.Items.Owned.RemoveAll(item => item.ObjectId == objectId); + host.Automation.Items.Properties.Remove(objectId); + host.Automation.Objects.Objects.RemoveAll(o => o.ObjectId == objectId); + } + + public static PluginWorldObject World(PluginInventoryItem item, bool hasProperties, int hostIdTime) + => new( + item.ObjectId, + item.WeenieClassId, + item.Name, + item.ObjectClass, + item.ItemType, + item.ContainerObjectId, + item.WielderObjectId) + { + IsOwned = true, + HasAppraisalData = hasProperties, + LastIdTime = hostIdTime, + SpellIds = item.AppraisedSpellIds, + IconId = item.IconId, + }; + + /// A change as the host raises it, with the object's current snapshot. + public static PluginObjectChange Change(FakeHost host, uint objectId, PluginObjectChangeKind kind) + => new(objectId, kind) + { + Current = host.Automation.Objects.TryGet(objectId, out PluginWorldObject current) + ? current + : null, + }; + + public static void Raise(FakeHost host, uint objectId, PluginObjectChangeKind kind) + => host.Events.RaiseObjectChanged(Change(host, objectId, kind)); + + /// Logs in and runs the ticks it takes the inventory to settle, without moving the clock. + public static InventoryFeature LoginAndSettle(PluginHarness harness) + { + harness.Login(); + InventoryFeature feature = harness.Feature(); + feature.IsBackendReady = () => true; + for (int i = 0; i < 40 && !feature.IsSettled; i++) + harness.Tick(0.1); + Assert.True(feature.IsSettled); + return feature; + } + + /// A stack of 57 Prismatic Tapers in the main pack, never appraised. + public static PluginInventoryItem Tapers(int stack = 57) => + new( + 0x80000101u, 20631u, "Prismatic Taper", 0x1000u, Me, 0u, 0u, 0u, + 1u, 0u, 18u, stack, 0, 0, 0u, 0, 0, 0u, false, 0d, 0, 0, 0, 0d, 0, 0, 0) + { + ObjectClass = PluginObjectClass.SpellComponent, + IconId = 0x06002D8Au, + MaximumStackSize = 100, + ContainerSlot = 0, + Burden = stack, + Value = stack * 10, + PluralName = "Prismatic Tapers", + }; + + /// A breastplate in the main pack, appraised, with two carried and two running spells. + public static PluginInventoryItem Breastplate() => + new( + 0x80000303u, 6044u, "Celdon Breastplate", 0x2u, Me, 0u, 512u, 0u, + 1u, 0u, 18u, 1, 0, 0, 0u, 0, 0, 0u, false, 0d, 0, 0, 0, 0d, 0, 0, 0) + { + ObjectClass = PluginObjectClass.Armor, + IconId = 0x06001234u, + ContainerSlot = 3, + Burden = 800, + Value = 5000, + CoverageMask = 1024u, + Effects = 1u, + AppraisedSpellIds = [1486u, 2604u, 0x80000000u | 4407u, 0x80000000u | 1483u], + }; + + public static PluginItemProperties BreastplateProps() => + Props( + ints: new() { [28] = 600, [265] = 5 }, + bools: new() { [69] = true }, + armor: new PluginArmorProfile(600, 1.2f, 1.0f, 0.8f, 0.6f, 0.4f, 0.2f, 0f, 1.5f)); + + /// A wielded sword, before (no spells) or after appraisal. + public static PluginInventoryItem Tachi(bool appraised) => + new( + 0x80000202u, 30310u, "Fire Tachi", 0x1u, 0u, Me, 1048576u, 1048576u, + 6291460u, 16u, 0x04000012u, 1, 0, 0, 0u, 0, 0, 0u, false, 0d, 0, 0, 0, 0d, 0, 0, 0) + { + ObjectClass = PluginObjectClass.MeleeWeapon, + IconId = 0x0600336Au, + IconOverlayId = 0x06001B2Eu, + IconUnderlayId = 0u, + ContainerSlot = -1, + CombatUse = 1, + Burden = 270, + Value = 12000, + MaterialType = 64u, + Workmanship = 8f, + Effects = 1u, + AppraisedSpellIds = appraised ? [2598u, 2603u, 0x80000000u | 5183u] : [], + }; + + public static PluginItemProperties TachiProps() => + Props( + ints: new() + { + [105] = 8, [107] = 1500, [108] = 2000, [109] = 300, [158] = 2, [159] = 45, + [160] = 370, [171] = 3, [179] = 1, [353] = 2, [370] = 2, + }, + floats: new() { [5] = -0.05, [29] = 1.15 }, + strings: new() { [16] = "Fire Tachi of Blood Drinker" }, + weapon: new PluginWeaponProfile(16, 30, 45u, 50, 0.5, 1.0, 1.5, 1.0, 1.2000000476837158, 0)); + + /// A ring the character wears, never appraised. + public static PluginInventoryItem Ring() => + new( + 0x80000404u, 2575u, "Gold Ring", 0x8u, 0u, Me, 0xC0000u, 0x40000u, + 1u, 0u, 18u, 1, 0, 0, 0u, 0, 0, 0u, false, 0d, 0, 0, 0, 0d, 0, 0, 0) + { + ObjectClass = PluginObjectClass.Jewelry, + IconId = 0x06000F6Eu, + ContainerSlot = -1, + Burden = 30, + Value = 400, + }; + + public static PluginSpellInfo Spell(uint id, uint family, int difficulty, bool debuff = false) + => new(id, $"Spell {id}", family, 1, difficulty, 10, -1f, 3u, string.Empty, false, true) + { + IsDebuff = debuff, + }; +} + +internal static class InventoryHarnessExtensions +{ + /// The raw frames of one type, in the order they were sent. + public static List FramesOfTypeRaw(this PluginHarness harness, string type) => + harness.Frames.Where(frame => (string?)JObject.Parse(frame)["type"] == type).ToList(); +}