diff --git a/src/OpenAC.MosswartMassacre/FeatureCatalog.cs b/src/OpenAC.MosswartMassacre/FeatureCatalog.cs
index b86fd7f..a4580a1 100644
--- a/src/OpenAC.MosswartMassacre/FeatureCatalog.cs
+++ b/src/OpenAC.MosswartMassacre/FeatureCatalog.cs
@@ -1,6 +1,7 @@
using OpenAC.MosswartMassacre.Backend;
using OpenAC.MosswartMassacre.FlagTracker;
using OpenAC.MosswartMassacre.Infrastructure;
+using OpenAC.MosswartMassacre.Inventory;
using OpenAC.MosswartMassacre.Metas;
using OpenAC.MosswartMassacre.Quests;
using OpenAC.MosswartMassacre.Streams;
@@ -64,5 +65,14 @@ internal static class FeatureCatalog
mainWindow.FlagTrackerOpener = () => trackerPanel.IsVisible = true;
if (metas.Panel is { } metasPanel)
mainWindow.MetasOpener = () => metasPanel.IsVisible = true;
+
+ // 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);
+ var inventory = new InventoryFeature(context, inventoryReader, identifyQueue);
+ features.Add(inventory);
+ context.PrismaticTaperCount = () => inventory.PrismaticTaperCount;
+ features.Add(new EquipmentCantripStateFeature(context, inventoryReader));
}
}
diff --git a/src/OpenAC.MosswartMassacre/Inventory/DecalItemBuilder.cs b/src/OpenAC.MosswartMassacre/Inventory/DecalItemBuilder.cs
new file mode 100644
index 0000000..a9b7124
--- /dev/null
+++ b/src/OpenAC.MosswartMassacre/Inventory/DecalItemBuilder.cs
@@ -0,0 +1,262 @@
+using AcDream.Plugin.Abstractions;
+
+namespace OpenAC.MosswartMassacre.Inventory;
+
+///
+/// Everything the builder reads for one item, gathered on the tick thread.
+///
+/// The item's header fields as the host reports them.
+/// The item's raw property tables and appraisal profiles, when the host has them.
+/// True once the item has been appraised this session.
+/// When it was appraised, in Unix seconds; zero when it has not been.
+/// The header words the contract does not expose yet.
+internal readonly record struct ItemSource(
+ PluginInventoryItem Item,
+ PluginItemProperties? Properties,
+ bool HasIdData,
+ int LastIdTime,
+ ItemHeaderWords Words);
+
+///
+/// Builds the item record the backend expects from what the host reports:
+/// the raw property tables as they are, plus the framework keys the original
+/// filled from the object's header and appraisal profiles.
+///
+///
+///
+/// Which keys appear, and when, follows the original's own item dumps: a
+/// header field's key is present exactly when the server sent that field,
+/// the value key is always present (-1 when the server sent none), the
+/// underlay key accompanies the second header word, the spell counts come
+/// with a non-empty appraised spell book, and the weapon and armor keys with
+/// their appraisal profiles. The slot key was never in the tables and is not
+/// written.
+///
+///
+/// The tables are written in ascending key order. The original wrote them in
+/// its framework's hash-table order, which depends on the insertion history
+/// and cannot be reproduced; the backend reads them by key.
+///
+///
+internal static class DecalItemBuilder
+{
+ /// The item's own active enchantments carry this bit in the appraised spell book.
+ public const uint ActiveSpellBit = 0x80000000u;
+
+ private const uint IconBase = 0x06000000u;
+
+ /// The original's MyWorldObjectCreator.Create .
+ public static MyWorldObject Create(in ItemSource source)
+ {
+ PluginInventoryItem item = source.Item;
+ ItemHeaderWords words = source.Words;
+ var ints = new Dictionary();
+ var doubles = new Dictionary();
+ var bools = new Dictionary();
+ var strings = new Dictionary();
+
+ if (source.Properties is { } properties)
+ {
+ foreach (KeyValuePair kvp in properties.Ints ?? EmptyInts)
+ ints[unchecked((int)kvp.Key)] = kvp.Value;
+ foreach (KeyValuePair kvp in properties.Floats ?? EmptyDoubles)
+ doubles[unchecked((int)kvp.Key)] = kvp.Value;
+ foreach (KeyValuePair kvp in properties.Bools ?? EmptyBools)
+ bools[unchecked((int)kvp.Key)] = kvp.Value;
+ foreach (KeyValuePair kvp in properties.Strings ?? EmptyStrings)
+ strings[unchecked((int)kvp.Key)] = kvp.Value;
+ }
+
+ bool Sent(uint flag, bool whenUnknown) =>
+ words.WeenieHeaderFlags is uint flags ? (flags & flag) != 0u : whenUnknown;
+
+ // Always in the header.
+ strings[DecalKeys.RawName] = item.Name;
+ ints[DecalKeys.WeenieClassId] = unchecked((int)item.WeenieClassId);
+ ints[DecalKeys.Icon] = ShortIcon(item.IconId);
+ ints[DecalKeys.Category] = unchecked((int)item.ItemType);
+ ints[DecalKeys.Behavior] = unchecked((int)item.PublicFlags);
+ int container = ContainerOf(item);
+ ints[DecalKeys.Container] = container;
+
+ if (Sent(HeaderFlags.PluralName, !string.IsNullOrEmpty(item.PluralName)))
+ strings[DecalKeys.SecondaryName] = item.PluralName;
+ if (Sent(HeaderFlags.ItemsCapacity | HeaderFlags.ContainersCapacity, item.ItemsCapacity != 0 || item.ContainersCapacity != 0))
+ {
+ ints[DecalKeys.ItemSlots] = item.ItemsCapacity;
+ ints[DecalKeys.PackSlots] = item.ContainersCapacity;
+ }
+ if (Sent(HeaderFlags.Value, item.Value != 0))
+ ints[DecalKeys.RawValue] = item.Value;
+ else if (!ints.ContainsKey(DecalKeys.RawValue))
+ ints[DecalKeys.RawValue] = -1;
+ if (Sent(HeaderFlags.Usable, item.Useability != 0u))
+ ints[DecalKeys.Usable] = unchecked((int)item.Useability);
+ if (Sent(HeaderFlags.UseRadius, item.UseRadius != 0f))
+ doubles[DecalKeys.ApproachDistance] = item.UseRadius;
+ if (Sent(HeaderFlags.UiEffects, item.Effects != 0u))
+ ints[DecalKeys.IconOutline] = unchecked((int)item.Effects);
+ if (Sent(HeaderFlags.AmmoType, item.AmmoType != 0u))
+ ints[DecalKeys.MissileType] = unchecked((int)item.AmmoType);
+ if (Sent(HeaderFlags.CombatUse, item.CombatUse != 0))
+ ints[DecalKeys.EquipType] = item.CombatUse;
+ if (Sent(HeaderFlags.Structure | HeaderFlags.MaxStructure, item.MaximumStructure != 0 || item.Structure != 0))
+ {
+ ints[DecalKeys.RawMaxStructure] = item.MaximumStructure;
+ ints[DecalKeys.RawStructure] = item.Structure;
+ }
+ if (Sent(HeaderFlags.StackSize | HeaderFlags.MaxStackSize, item.MaximumStackSize > 1))
+ {
+ ints[DecalKeys.StackCount] = item.StackSize;
+ ints[DecalKeys.StackMax] = item.MaximumStackSize;
+ }
+ if (Sent(HeaderFlags.ValidLocations, item.ValidLocations != 0u))
+ ints[DecalKeys.EquipableSlots] = unchecked((int)item.ValidLocations);
+ if (Sent(HeaderFlags.CurrentlyWieldedLocation, item.EquippedLocation != 0u))
+ ints[DecalKeys.RawEquippedSlots] = unchecked((int)item.EquippedLocation);
+ if (Sent(HeaderFlags.Priority, item.CoverageMask != 0u))
+ ints[DecalKeys.Coverage] = unchecked((int)item.CoverageMask);
+ if (Sent(HeaderFlags.TargetType, item.TargetType != 0u))
+ ints[DecalKeys.UsageMask] = unchecked((int)item.TargetType);
+ if (Sent(HeaderFlags.Burden, item.Burden != 0))
+ ints[DecalKeys.RawBurden] = item.Burden;
+ if (Sent(HeaderFlags.Spell, item.SpellId != 0u))
+ ints[DecalKeys.SpellDid] = unchecked((int)item.SpellId);
+ if (Sent(HeaderFlags.Workmanship, item.Workmanship != 0f))
+ doubles[DecalKeys.SalvageWorkmanship] = item.Workmanship;
+ if (words.HookType is int hookType && Sent(HeaderFlags.HookType, true))
+ ints[DecalKeys.HookType] = hookType;
+ if (Sent(HeaderFlags.IconOverlay, item.IconOverlayId != 0u))
+ ints[DecalKeys.IconOverlay] = ShortIcon(item.IconOverlayId);
+ if (Sent(HeaderFlags.MaterialType, item.MaterialType != 0u))
+ ints[DecalKeys.RawMaterial] = unchecked((int)item.MaterialType);
+
+ if ((item.PublicFlags & HeaderFlags.IncludesSecondHeader) != 0u)
+ {
+ ints[DecalKeys.IconUnderlay] = ShortIcon(item.IconUnderlayId);
+ if (words.WeenieHeaderFlags2 is uint flags2)
+ ints[DecalKeys.CreateFlags2] = unchecked((int)flags2);
+ }
+
+ if (words.WeenieHeaderFlags is uint headerFlags)
+ ints[DecalKeys.CreateFlags1] = unchecked((int)headerFlags);
+ if (words.PhysicsDescriptionFlags is uint physicsFlags)
+ ints[DecalKeys.PhysicsDataFlags] = unchecked((int)physicsFlags);
+ if (words.PhysicsWord is uint physicsWord)
+ ints[DecalKeys.PhysicsWord] = unchecked((int)physicsWord);
+ if (words.SetupId is uint setup)
+ ints[DecalKeys.Setup] = unchecked((int)setup);
+ if (words.Scale is double scale)
+ doubles[DecalKeys.Scale] = scale;
+ if (words.ParentObjectId is uint parent)
+ ints[DecalKeys.Wielder] = unchecked((int)parent);
+ if (words.ParentLocation is int parentLocation)
+ ints[DecalKeys.WieldingSlot] = parentLocation;
+
+ // From the appraisal.
+ var spells = new List();
+ var activeSpells = new List();
+ IReadOnlyList spellBook = item.AppraisedSpellIds ?? Array.Empty();
+ foreach (uint entry in spellBook)
+ {
+ if ((entry & ActiveSpellBit) != 0u)
+ activeSpells.Add(unchecked((int)(entry & ~ActiveSpellBit)));
+ else
+ spells.Add(unchecked((int)entry));
+ }
+ if (spellBook.Count > 0)
+ {
+ ints[DecalKeys.SpellCount] = spells.Count;
+ ints[DecalKeys.ActiveSpellCount] = activeSpells.Count;
+ }
+
+ if (source.Properties?.WeaponProfile is { } weapon)
+ {
+ ints[DecalKeys.WeapSpeed] = weapon.WeaponTime;
+ ints[DecalKeys.EquipSkill] = unchecked((int)weapon.WeaponSkill);
+ ints[DecalKeys.DamageType] = weapon.DamageType;
+ ints[DecalKeys.MaxDamage] = weapon.Damage;
+ doubles[DecalKeys.Variance] = weapon.DamageVariance;
+ doubles[DecalKeys.AttackBonus] = weapon.WeaponOffense;
+ doubles[DecalKeys.Range] = weapon.MaxVelocity;
+ doubles[DecalKeys.DamageBonus] = weapon.DamageMod;
+ }
+
+ if (source.Properties?.ArmorProfile is { } armor)
+ {
+ doubles[DecalKeys.SlashProt] = armor.SlashMod;
+ doubles[DecalKeys.PierceProt] = armor.PierceMod;
+ doubles[DecalKeys.BludgeonProt] = armor.BludgeonMod;
+ doubles[DecalKeys.AcidProt] = armor.AcidMod;
+ doubles[DecalKeys.LightningProt] = armor.ElectricMod;
+ doubles[DecalKeys.FireProt] = armor.FireMod;
+ doubles[DecalKeys.ColdProt] = armor.ColdMod;
+ }
+
+ return new MyWorldObject
+ {
+ HasIdData = source.HasIdData,
+ Id = unchecked((int)item.ObjectId),
+ LastIdTime = source.LastIdTime,
+ ObjectClass = (int)item.ObjectClass,
+ Icon = ShortIcon(item.IconId),
+ ContainerId = container,
+ BoolValues = Sorted(bools),
+ DoubleValues = Sorted(doubles),
+ IntValues = Sorted(ints),
+ StringValues = Sorted(strings),
+ ActiveSpells = activeSpells,
+ Spells = spells,
+ };
+ }
+
+ ///
+ /// The original's MyWorldObjectCreator.Combine : an item remembered
+ /// with appraisal data from an earlier session keeps that data while the
+ /// live item has none, refreshed by whatever the live item does carry.
+ ///
+ public static MyWorldObject Combine(MyWorldObject older, MyWorldObject newer)
+ {
+ // Repairs an icon missing from an older saved record.
+ if (older.Icon == 0 && newer.Icon != 0)
+ older.Icon = newer.Icon;
+
+ if (!older.HasIdData || newer.HasIdData)
+ return newer;
+
+ older.AddTo(newer.BoolValues, newer.DoubleValues, newer.IntValues, newer.StringValues);
+ older.ContainerId = newer.ContainerId;
+ older.BoolValues = Sorted(older.BoolValues);
+ older.DoubleValues = Sorted(older.DoubleValues);
+ older.IntValues = Sorted(older.IntValues);
+ older.StringValues = Sorted(older.StringValues);
+ return older;
+ }
+
+ ///
+ /// An icon id as the framework reported it: without the icon table's
+ /// 0x06000000 prefix.
+ ///
+ public static int ShortIcon(uint iconId) =>
+ iconId >= IconBase ? unchecked((int)(iconId - IconBase)) : unchecked((int)iconId);
+
+ ///
+ /// The framework's container of an item: the pack holding it, or the
+ /// creature wielding it when it is wielded.
+ ///
+ public static int ContainerOf(in PluginInventoryItem item) =>
+ unchecked((int)(item.ContainerObjectId != 0u ? item.ContainerObjectId : item.WielderObjectId));
+
+ private static Dictionary Sorted(Dictionary table)
+ {
+ var sorted = new Dictionary(table.Count);
+ foreach (int key in table.Keys.Order())
+ sorted[key] = table[key];
+ return sorted;
+ }
+
+ private static readonly IReadOnlyDictionary EmptyInts = new Dictionary();
+ private static readonly IReadOnlyDictionary EmptyDoubles = new Dictionary();
+ private static readonly IReadOnlyDictionary EmptyBools = new Dictionary();
+ private static readonly IReadOnlyDictionary EmptyStrings = new Dictionary();
+}
diff --git a/src/OpenAC.MosswartMassacre/Inventory/DecalKeys.cs b/src/OpenAC.MosswartMassacre/Inventory/DecalKeys.cs
new file mode 100644
index 0000000..a4a5377
--- /dev/null
+++ b/src/OpenAC.MosswartMassacre/Inventory/DecalKeys.cs
@@ -0,0 +1,110 @@
+namespace OpenAC.MosswartMassacre.Inventory;
+
+///
+/// The pseudo keys the original's client framework mixed into an item's
+/// property tables next to the raw game property ids. The backend reads
+/// them by these numbers, so they are part of the wire.
+///
+internal static class DecalKeys
+{
+ // Integer table.
+ public const int WeenieClassId = 218103808;
+ public const int Icon = 218103809;
+ public const int Container = 218103810;
+ public const int ItemSlots = 218103812;
+ public const int PackSlots = 218103813;
+ public const int StackCount = 218103814;
+ public const int StackMax = 218103815;
+ public const int SpellDid = 218103816;
+ public const int Wielder = 218103818;
+ public const int WieldingSlot = 218103819;
+ public const int Coverage = 218103821;
+ public const int EquipableSlots = 218103822;
+ public const int EquipType = 218103823;
+ public const int IconOutline = 218103824;
+ public const int MissileType = 218103825;
+ public const int UsageMask = 218103826;
+ public const int HookType = 218103828;
+ public const int Setup = 218103830;
+ public const int PhysicsWord = 218103831;
+ public const int CreateFlags1 = 218103832;
+ public const int CreateFlags2 = 218103833;
+ public const int Category = 218103834;
+ public const int Behavior = 218103835;
+ public const int SpellCount = 218103838;
+ public const int WeapSpeed = 218103839;
+ public const int EquipSkill = 218103840;
+ public const int DamageType = 218103841;
+ public const int MaxDamage = 218103842;
+ public const int Usable = 218103843;
+ public const int PhysicsDataFlags = 218103847;
+ public const int ActiveSpellCount = 218103848;
+ public const int IconOverlay = 218103849;
+ public const int IconUnderlay = 218103850;
+
+ // Floating-point table.
+ public const int SlashProt = 167772160;
+ public const int PierceProt = 167772161;
+ public const int BludgeonProt = 167772162;
+ public const int AcidProt = 167772163;
+ public const int LightningProt = 167772164;
+ public const int FireProt = 167772165;
+ public const int ColdProt = 167772166;
+ public const int ApproachDistance = 167772168;
+ public const int SalvageWorkmanship = 167772169;
+ public const int Scale = 167772170;
+ public const int Variance = 167772171;
+ public const int AttackBonus = 167772172;
+ public const int Range = 167772173;
+ public const int DamageBonus = 167772174;
+
+ // Text table.
+ public const int SecondaryName = 184549376;
+
+ // Raw game property ids the framework filled from the object's header.
+ public const int RawBurden = 5;
+ public const int RawEquippedSlots = 10;
+ public const int RawValue = 19;
+ public const int RawMaxStructure = 91;
+ public const int RawStructure = 92;
+ public const int RawMaterial = 131;
+ public const int RawName = 1;
+ public const int RawManaRate = 5;
+ public const int RawCurrentMana = 107;
+ public const int RawMaximumMana = 108;
+}
+
+///
+/// The bits of the object header's first flag word, which say which optional
+/// header fields the server sent. The framework's key tables held a field
+/// exactly when its bit was set.
+///
+internal static class HeaderFlags
+{
+ public const uint PluralName = 0x00000001;
+ public const uint ItemsCapacity = 0x00000002;
+ public const uint ContainersCapacity = 0x00000004;
+ public const uint Value = 0x00000008;
+ public const uint Usable = 0x00000010;
+ public const uint UseRadius = 0x00000020;
+ public const uint UiEffects = 0x00000080;
+ public const uint AmmoType = 0x00000100;
+ public const uint CombatUse = 0x00000200;
+ public const uint Structure = 0x00000400;
+ public const uint MaxStructure = 0x00000800;
+ public const uint StackSize = 0x00001000;
+ public const uint MaxStackSize = 0x00002000;
+ public const uint ValidLocations = 0x00010000;
+ public const uint CurrentlyWieldedLocation = 0x00020000;
+ public const uint Priority = 0x00040000;
+ public const uint TargetType = 0x00080000;
+ public const uint Burden = 0x00200000;
+ public const uint Spell = 0x00400000;
+ public const uint Workmanship = 0x01000000;
+ public const uint HookType = 0x10000000;
+ public const uint IconOverlay = 0x40000000;
+ public const uint MaterialType = 0x80000000;
+
+ /// The public-flags bit that says a second header word follows.
+ public const uint IncludesSecondHeader = 0x04000000;
+}
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..7df03bc
--- /dev/null
+++ b/src/OpenAC.MosswartMassacre/Inventory/InventoryFeature.cs
@@ -0,0 +1,603 @@
+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;
+
+ // 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;
+ _context.Host.Events.ObjectChanged += _onObjectChanged;
+ _context.Host.Events.Tick += _onTick;
+
+ 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);
+ }
+ }
+
+ ///
+ /// The original dumped the inventory at logoff. The root logs features
+ /// off in reverse order, the socket last, so this frame still goes out.
+ ///
+ public void OnLogoff()
+ {
+ OnHostLogoff();
+ _active = false;
+ ResetState();
+ }
+
+ public void Dispose()
+ {
+ _context.Host.Events.ObjectChanged -= _onObjectChanged;
+ _context.Host.Events.Tick -= _onTick;
+ 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)
+ {
+ _reader.NoteAppraisal(change.ObjectId);
+ _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..c5712bb
--- /dev/null
+++ b/src/OpenAC.MosswartMassacre/Inventory/InventoryReader.cs
@@ -0,0 +1,146 @@
+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;
+
+ ///
+ /// Stamps an appraisal as it arrives, so the record carries the time it
+ /// came in rather than the time the record was first built.
+ ///
+ public void NoteAppraisal(uint objectId)
+ {
+ if (_context.Automation.Objects.TryGet(objectId, out PluginWorldObject world) && world.LastIdTime != 0)
+ Stamp(objectId, world.LastIdTime);
+ }
+
+ /// 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/src/OpenAC.MosswartMassacre/Inventory/ItemDictionaries.cs b/src/OpenAC.MosswartMassacre/Inventory/ItemDictionaries.cs
new file mode 100644
index 0000000..b82efe4
--- /dev/null
+++ b/src/OpenAC.MosswartMassacre/Inventory/ItemDictionaries.cs
@@ -0,0 +1,441 @@
+namespace OpenAC.MosswartMassacre.Inventory;
+
+///
+/// The lookup tables the item record's getters read (material, skill, mastery
+/// and item-set names; the item-spell effects behind the "buffed" and
+/// "calced" numbers). Copied verbatim from the original plugin's shared code:
+/// the getters' output is part of the wire, so a changed name or number here
+/// changes a frame.
+///
+internal static class ItemDictionaries
+{
+ ///
+ /// Returns a dictionary of skill ids vs names
+ ///
+ ///
+ public static readonly Dictionary SkillInfo = new Dictionary
+ {
+ // This list was taken from the Alinco source
+ { 0x1, "Axe" },
+ { 0x2, "Bow" },
+ { 0x3, "Crossbow" },
+ { 0x4, "Dagger" },
+ { 0x5, "Mace" },
+ { 0x6, "Melee Defense" },
+ { 0x7, "Missile Defense" },
+ { 0x8, "Sling" },
+ { 0x9, "Spear" },
+ { 0xA, "Staff" },
+ { 0xB, "Sword" },
+ { 0xC, "Thrown Weapons" },
+ { 0xD, "Unarmed Combat" },
+ { 0xE, "Arcane Lore" },
+ { 0xF, "Magic Defense" },
+ { 0x10, "Mana Conversion" },
+ { 0x12, "Item Tinkering" },
+ { 0x13, "Assess Person" },
+ { 0x14, "Deception" },
+ { 0x15, "Healing" },
+ { 0x16, "Jump" },
+ { 0x17, "Lockpick" },
+ { 0x18, "Run" },
+ { 0x1B, "Assess Creature" },
+ { 0x1C, "Weapon Tinkering" },
+ { 0x1D, "Armor Tinkering" },
+ { 0x1E, "Magic Item Tinkering" },
+ { 0x1F, "Creature Enchantment" },
+ { 0x20, "Item Enchantment" },
+ { 0x21, "Life Magic" },
+ { 0x22, "War Magic" },
+ { 0x23, "Leadership" },
+ { 0x24, "Loyalty" },
+ { 0x25, "Fletching" },
+ { 0x26, "Alchemy" },
+ { 0x27, "Cooking" },
+ { 0x28, "Salvaging" },
+ { 0x29, "Two Handed Combat" },
+ { 0x2A, "Gearcraft"},
+ { 0x2B, "Void" },
+ { 0x2C, "Heavy Weapons" },
+ { 0x2D, "Light Weapons" },
+ { 0x2E, "Finesse Weapons" },
+ { 0x2F, "Missile Weapons" },
+ { 0x30, "Shield" },
+ { 0x31, "Dual Wield" },
+ { 0x32, "Recklessness" },
+ { 0x33, "Sneak Attack" },
+ { 0x34, "Dirty Fighting" },
+ { 0x35, "Challenge" },
+ { 0x36, "Summoning" },
+ };
+
+ ///
+ /// Returns a dictionary of mastery ids vs names
+ ///
+ ///
+ public static readonly Dictionary MasteryInfo = new Dictionary
+ {
+ { 1, "Unarmed Weapon" },
+ { 2, "Sword" },
+ { 3, "Axe" },
+ { 4, "Mace" },
+ { 5, "Spear" },
+ { 6, "Dagger" },
+ { 7, "Staff" },
+ { 8, "Bow" },
+ { 9, "Crossbow" },
+ { 10, "Thrown" },
+ { 11, "Two Handed Combat" },
+ };
+
+ ///
+ /// Returns a dictionary of attribute set ids vs names
+ ///
+ ///
+ public static readonly Dictionary AttributeSetInfo = new Dictionary
+ {
+ // This list was taken from Virindi Tank Loot Editor
+ // 01
+ { 02, "Test"},
+ // 03
+ { 04, "Carraida's Benediction"},
+ { 05, "Noble Relic Set" },
+ { 06, "Ancient Relic Set" },
+ { 07, "Relic Alduressa Set" },
+ { 08, "Shou-jen Set" },
+ { 09, "Empyrean Rings Set" },
+ { 10, "Arm, Mind, Heart Set" },
+ { 11, "Coat of the Perfect Light Set" },
+ { 12, "Leggings of Perfect Light Set" },
+ { 13, "Soldier's Set" },
+ { 14, "Adept's Set" },
+ { 15, "Archer's Set" },
+ { 16, "Defender's Set" },
+ { 17, "Tinker's Set" },
+ { 18, "Crafter's Set" },
+ { 19, "Hearty Set" },
+ { 20, "Dexterous Set" },
+ { 21, "Wise Set" },
+ { 22, "Swift Set" },
+ { 23, "Hardenend Set" },
+ { 24, "Reinforced Set" },
+ { 25, "Interlocking Set" },
+ { 26, "Flame Proof Set" },
+ { 27, "Acid Proof Set" },
+ { 28, "Cold Proof Set" },
+ { 29, "Lightning Proof Set" },
+ { 30, "Dedication Set" },
+ { 31, "Gladiatorial Clothing Set" },
+ { 32, "Ceremonial Clothing" },
+ { 33, "Protective Clothing" },
+ { 34, "Noobie Armor" },
+ { 35, "Sigil of Defense" },
+ { 36, "Sigil of Destruction" },
+ { 37, "Sigil of Fury" },
+ { 38, "Sigil of Growth" },
+ { 39, "Sigil of Vigor" },
+ { 40, "Heroic Protector Set" },
+ { 41, "Heroic Destroyer Set" },
+ { 42, "Olthoi Armor D Red" },
+ { 43, "Olthoi Armor C Rat" },
+ { 44, "Olthoi Armor C Red" },
+ { 45, "Olthoi Armor D Rat" },
+ { 46, "Upgraded Relic Alduressa Set" },
+ { 47, "Upgraded Ancient Relic Set" },
+ { 48, "Upgraded Noble Relic Set" },
+ { 49, "Weave of Alchemy" },
+ { 50, "Weave of Arcane Lore" },
+ { 51, "Weave of Armor Tinkering" },
+ { 52, "Weave of Assess Person" },
+ { 53, "Weave of Light Weapons" },
+ { 54, "Weave of Missile Weapons" },
+ { 55, "Weave of Cooking" },
+ { 56, "Weave of Creature Enchantment" },
+ { 57, "Weave of Missile Weapons" },
+ { 58, "Weave of Finesse" },
+ { 59, "Weave of Deception" },
+ { 60, "Weave of Fletching" },
+ { 61, "Weave of Healing" },
+ { 62, "Weave of Item Enchantment" },
+ { 63, "Weave of Item Tinkering" },
+ { 64, "Weave of Leadership" },
+ { 65, "Weave of Life Magic" },
+ { 66, "Weave of Loyalty" },
+ { 67, "Weave of Light Weapons" },
+ { 68, "Weave of Magic Defense" },
+ { 69, "Weave of Magic Item Tinkering" },
+ { 70, "Weave of Mana Conversion" },
+ { 71, "Weave of Melee Defense" },
+ { 72, "Weave of Missile Defense" },
+ { 73, "Weave of Salvaging" },
+ { 74, "Weave of Light Weapons" },
+ { 75, "Weave of Light Weapons" },
+ { 76, "Weave of Heavy Weapons" },
+ { 77, "Weave of Missile Weapons" },
+ { 78, "Weave of Two Handed Combat" },
+ { 79, "Weave of Light Weapons" },
+ { 80, "Weave of Void Magic" },
+ { 81, "Weave of War Magic" },
+ { 82, "Weave of Weapon Tinkering" },
+ { 83, "Weave of Assess Creature " },
+ { 84, "Weave of Dirty Fighting" },
+ { 85, "Weave of Dual Wield" },
+ { 86, "Weave of Recklessness" },
+ { 87, "Weave of Shield" },
+ { 88, "Weave of Sneak Attack" },
+ { 89, "Ninja_New" },
+ { 90, "Weave of Summoning" },
+
+ { 91, "Shrouded Soul" },
+ { 92, "Darkened Mind" },
+ { 93, "Clouded Spirit" },
+ { 94, "Minor Stinging Shrouded Soul" },
+ { 95, "Minor Sparking Shrouded Soul" },
+ { 96, "Minor Smoldering Shrouded Soul" },
+ { 97, "Minor Shivering Shrouded Soul" },
+ { 98, "Minor Stinging Darkened Mind" },
+ { 99, "Minor Sparking Darkened Mind" },
+
+ { 100, "Minor Smoldering Darkened Mind" },
+ { 101, "Minor Shivering Darkened Mind" },
+ { 102, "Minor Stinging Clouded Spirit" },
+ { 103, "Minor Sparking Clouded Spirit" },
+ { 104, "Minor Smoldering Clouded Spirit" },
+ { 105, "Minor Shivering Clouded Spirit" },
+ { 106, "Major Stinging Shrouded Soul" },
+ { 107, "Major Sparking Shrouded Soul" },
+ { 108, "Major Smoldering Shrouded Soul" },
+ { 109, "Major Shivering Shrouded Soul" },
+
+ { 110, "Major Stinging Darkened Mind" },
+ { 111, "Major Sparking Darkened Mind" },
+ { 112, "Major Smoldering Darkened Mind" },
+ { 113, "Major Shivering Darkened Mind" },
+ { 114, "Major Stinging Clouded Spirit" },
+ { 115, "Major Sparking Clouded Spirit" },
+ { 116, "Major Smoldering Clouded Spirit" },
+ { 117, "Major Shivering Clouded Spirit" },
+ { 118, "Blackfire Stinging Shrouded Soul" },
+ { 119, "Blackfire Sparking Shrouded Soul" },
+
+ { 120, "Blackfire Smoldering Shrouded Soul" },
+ { 121, "Blackfire Shivering Shrouded Soul" },
+ { 122, "Blackfire Stinging Darkened Mind" },
+ { 123, "Blackfire Sparking Darkened Mind" },
+ { 124, "Blackfire Smoldering Darkened Mind" },
+ { 125, "Blackfire Shivering Darkened Mind" },
+ { 126, "Blackfire Stinging Clouded Spirit" },
+ { 127, "Blackfire Sparking Clouded Spirit" },
+ { 128, "Blackfire Smoldering Clouded Spirit" },
+ { 129, "Blackfire Shivering Clouded Spirit" },
+
+ { 130, "Shimmering Shadows" },
+
+ { 131, "Brown Society Locket" },
+ { 132, "Yellow Society Locket" },
+ { 133, "Red Society Band" },
+ { 134, "Green Society Band" },
+ { 135, "Purple Society Band" },
+ { 136, "Blue Society Band" },
+
+ { 137, "Gauntlet Garb" },
+
+ { 138, "UNKNOWN_138" }, // Possibly Paragon Missile Weapons
+ { 139, "UNKNOWN_139" }, // Possibly Paragon Casters
+ { 140, "UNKNOWN_140" }, // Possibly Paragon Melee Weapons
+ };
+
+ ///
+ /// Returns a dictionary of material ids vs names
+ ///
+ ///
+ public static readonly Dictionary MaterialInfo = new Dictionary
+ {
+ { 1, "Ceramic" },
+ { 2, "Porcelain" },
+ // 3
+ { 4, "Linen" },
+ { 5, "Satin" },
+ { 6, "Silk" },
+ { 7, "Velvet" },
+ { 8, "Wool" },
+ // 9
+ { 10, "Agate" },
+ { 11, "Amber" },
+ { 12, "Amethyst" },
+ { 13, "Aquamarine" },
+ { 14, "Azurite" },
+ { 15, "Black Garnet" },
+ { 16, "Black Opal" },
+ { 17, "Bloodstone" },
+ { 18, "Carnelian" },
+ { 19, "Citrine" },
+ { 20, "Diamond" },
+ { 21, "Emerald" },
+ { 22, "Fire Opal" },
+ { 23, "Green Garnet" },
+ { 24, "Green Jade" },
+ { 25, "Hematite" },
+ { 26, "Imperial Topaz" },
+ { 27, "Jet" },
+ { 28, "Lapis Lazuli" },
+ { 29, "Lavender Jade" },
+ { 30, "Malachite" },
+ { 31, "Moonstone" },
+ { 32, "Onyx" },
+ { 33, "Opal" },
+ { 34, "Peridot" },
+ { 35, "Red Garnet" },
+ { 36, "Red Jade" },
+ { 37, "Rose Quartz" },
+ { 38, "Ruby" },
+ { 39, "Sapphire" },
+ { 40, "Smokey Quartz" },
+ { 41, "Sunstone" },
+ { 42, "Tiger Eye" },
+ { 43, "Tourmaline" },
+ { 44, "Turquoise" },
+ { 45, "White Jade" },
+ { 46, "White Quartz" },
+ { 47, "White Sapphire" },
+ { 48, "Yellow Garnet" },
+ { 49, "Yellow Topaz" },
+ { 50, "Zircon" },
+ { 51, "Ivory" },
+ { 52, "Leather" },
+ { 53, "Armoredillo Hide" },
+ { 54, "Gromnie Hide" },
+ { 55, "Reed Shark Hide" },
+ // 56
+ { 57, "Brass" },
+ { 58, "Bronze" },
+ { 59, "Copper" },
+ { 60, "Gold" },
+ { 61, "Iron" },
+ { 62, "Pyreal" },
+ { 63, "Silver" },
+ { 64, "Steel" },
+ // 65
+ { 66, "Alabaster" },
+ { 67, "Granite" },
+ { 68, "Marble" },
+ { 69, "Obsidian" },
+ { 70, "Sandstone" },
+ { 71, "Serpentine" },
+ { 73, "Ebony" },
+ { 74, "Mahogany" },
+ { 75, "Oak" },
+ { 76, "Pine" },
+ { 77, "Teak" },
+ };
+
+ public struct SpellInfo
+ where T : struct
+ {
+ public readonly int Key;
+ public readonly T Change;
+ public readonly T Bonus;
+
+ public SpellInfo(int key, T change, T bonus = default)
+ {
+ Key = key;
+ Change = change;
+ Bonus = bonus;
+ }
+ }
+
+ // Taken from Decal.Adapter.Wrappers.LongValueKey
+ const int LongValueKey_MaxDamage = 218103842;
+ const int LongValueKey_ArmorLevel = 28;
+
+ public static readonly Dictionary> LongValueKeySpellEffects = new Dictionary>()
+ {
+ // In 2012 they removed these item spells and converted them to auras that are cast on the player, not on the item.
+ { 1616, new SpellInfo(LongValueKey_MaxDamage, 20)}, // Blood Drinker VI
+ { 2096, new SpellInfo(LongValueKey_MaxDamage, 22)}, // Infected Caress
+ //{ 5183, new SpellInfo(LongValueKey_MaxDamage, 22)}, // Incantation of Blood Drinker Pre Feb-2013
+ //{ 4395, new SpellInfo(LongValueKey_MaxDamage, 24, 2)}, // Incantation of Blood Drinker, this spell on the item adds 2 more points of damage over a user casted 8 Pre Feb-2013
+ { 5183, new SpellInfo(LongValueKey_MaxDamage, 24)}, // Incantation of Blood Drinker Post Feb-2013
+ { 4395, new SpellInfo(LongValueKey_MaxDamage, 24)}, // Incantation of Blood Drinker Post Feb-2013
+
+ { 2598, new SpellInfo(LongValueKey_MaxDamage, 2, 2)}, // Minor Blood Thirst
+ { 2586, new SpellInfo(LongValueKey_MaxDamage, 4, 4)}, // Major Blood Thirst
+ { 4661, new SpellInfo(LongValueKey_MaxDamage, 7, 7)}, // Epic Blood Thirst
+ { 6089, new SpellInfo(LongValueKey_MaxDamage, 10, 10)}, // Legendary Blood Thirst
+
+ { 3688, new SpellInfo(LongValueKey_MaxDamage, 300)}, // Prodigal Blood Drinker
+
+
+ { 1486, new SpellInfo(LongValueKey_ArmorLevel, 200)}, // Impenetrability VI
+ { 2108, new SpellInfo(LongValueKey_ArmorLevel, 220)}, // Brogard's Defiance
+ { 4407, new SpellInfo(LongValueKey_ArmorLevel, 240)}, // Incantation of Impenetrability
+
+ { 2604, new SpellInfo(LongValueKey_ArmorLevel, 20, 20)}, // Minor Impenetrability
+ { 2592, new SpellInfo(LongValueKey_ArmorLevel, 40, 40)}, // Major Impenetrability
+ { 4667, new SpellInfo(LongValueKey_ArmorLevel, 60, 60)}, // Epic Impenetrability
+ { 6095, new SpellInfo(LongValueKey_ArmorLevel, 80, 80)}, // Legendary Impenetrability
+ };
+
+ // Taken from Decal.Adapter.Wrappers.DoubleValueKey
+ const int DoubleValueKey_ElementalDamageVersusMonsters = 152;
+ const int DoubleValueKey_AttackBonus = 167772172;
+ const int DoubleValueKey_MeleeDefenseBonus = 29;
+ const int DoubleValueKey_ManaCBonus = 144;
+
+ public static readonly Dictionary> DoubleValueKeySpellEffects = new Dictionary>()
+ {
+ // In 2012 they removed these item spells and converted them to auras that are cast on the player, not on the item.
+ { 3258, new SpellInfo(DoubleValueKey_ElementalDamageVersusMonsters, .06)}, // Spirit Drinker VI
+ { 3259, new SpellInfo(DoubleValueKey_ElementalDamageVersusMonsters, .07)}, // Infected Spirit Caress
+ //{ 5182, new SpellInfo(DoubleValueKey_ElementalDamageVersusMonsters, .07)}, // Incantation of Spirit Drinker Pre Feb-2013
+ //{ 4414, new SpellInfo(DoubleValueKey_ElementalDamageVersusMonsters, .08, .01)}, // Incantation of Spirit Drinker, this spell on the item adds 1 more % of damage over a user casted 8 Pre Feb-2013
+ { 5182, new SpellInfo(DoubleValueKey_ElementalDamageVersusMonsters, .08)}, // Incantation of Spirit Drinker Post Feb-2013
+ { 4414, new SpellInfo(DoubleValueKey_ElementalDamageVersusMonsters, .08)}, // Incantation of Spirit Drinker, this spell on the item adds 1 more % of damage over a user casted 8 Post Feb-2013
+
+ { 3251, new SpellInfo(DoubleValueKey_ElementalDamageVersusMonsters, .01, .01)}, // Minor Spirit Thirst
+ { 3250, new SpellInfo(DoubleValueKey_ElementalDamageVersusMonsters, .03, .03)}, // Major Spirit Thirst
+ { 4670, new SpellInfo(DoubleValueKey_ElementalDamageVersusMonsters, .05, .05)}, // Epic Spirit Thirst
+ { 6098, new SpellInfo(DoubleValueKey_ElementalDamageVersusMonsters, .07, .07)}, // Legendary Spirit Thirst
+
+ { 3735, new SpellInfo(DoubleValueKey_ElementalDamageVersusMonsters, .15)}, // Prodigal Spirit Drinker
+
+
+ // In 2012 they removed these item spells and converted them to auras that are cast on the player, not on the item.
+ { 1592, new SpellInfo(DoubleValueKey_AttackBonus, .15)}, // Heart Seeker VI
+ { 2106, new SpellInfo(DoubleValueKey_AttackBonus, .17)}, // Elysa's Sight
+ { 4405, new SpellInfo(DoubleValueKey_AttackBonus, .20)}, // Incantation of Heart Seeker
+
+ { 2603, new SpellInfo(DoubleValueKey_AttackBonus, .03, .03)}, // Minor Heart Thirst
+ { 2591, new SpellInfo(DoubleValueKey_AttackBonus, .05, .05)}, // Major Heart Thirst
+ { 4666, new SpellInfo(DoubleValueKey_AttackBonus, .07, .07)}, // Epic Heart Thirst
+ { 6094, new SpellInfo(DoubleValueKey_AttackBonus, .09, .09)}, // Legendary Heart Thirst
+
+
+ // In 2012 they removed these item spells and converted them to auras that are cast on the player, not on the item.
+ { 1605, new SpellInfo(DoubleValueKey_MeleeDefenseBonus, .15)}, // Defender VI
+ { 2101, new SpellInfo(DoubleValueKey_MeleeDefenseBonus, .17)}, // Cragstone's Will
+ //{ 4400, new SpellInfo(DoubleValueKey_MeleeDefenseBonus, .17)}, // Incantation of Defender Pre Feb-2013
+ { 4400, new SpellInfo(DoubleValueKey_MeleeDefenseBonus, .20)}, // Incantation of Defender Post Feb-2013
+
+ { 2600, new SpellInfo(DoubleValueKey_MeleeDefenseBonus, .03, .03)}, // Minor Defender
+ { 3985, new SpellInfo(DoubleValueKey_MeleeDefenseBonus, .04, .04)}, // Mukkir Sense
+ { 2588, new SpellInfo(DoubleValueKey_MeleeDefenseBonus, .05, .05)}, // Major Defender
+ { 4663, new SpellInfo(DoubleValueKey_MeleeDefenseBonus, .07, .07)}, // Epic Defender
+ { 6091, new SpellInfo(DoubleValueKey_MeleeDefenseBonus, .09, .09)}, // Legendary Defender
+
+ { 3699, new SpellInfo(DoubleValueKey_MeleeDefenseBonus, .25)}, // Prodigal Defender
+
+
+ // In 2012 they removed these item spells and converted them to auras that are cast on the player, not on the item.
+ { 1480, new SpellInfo(DoubleValueKey_ManaCBonus, 1.60)}, // Hermetic Link VI
+ { 2117, new SpellInfo(DoubleValueKey_ManaCBonus, 1.70)}, // Mystic's Blessing
+ { 4418, new SpellInfo(DoubleValueKey_ManaCBonus, 1.80)}, // Incantation of Hermetic Link
+
+ { 3201, new SpellInfo(DoubleValueKey_ManaCBonus, 1.05, 1.05)}, // Feeble Hermetic Link
+ { 3199, new SpellInfo(DoubleValueKey_ManaCBonus, 1.10, 1.10)}, // Minor Hermetic Link
+ { 3202, new SpellInfo(DoubleValueKey_ManaCBonus, 1.15, 1.15)}, // Moderate Hermetic Link
+ { 3200, new SpellInfo(DoubleValueKey_ManaCBonus, 1.20, 1.20)}, // Major Hermetic Link
+ { 6086, new SpellInfo(DoubleValueKey_ManaCBonus, 1.25, 1.25)}, // Epic Hermetic Link
+ { 6087, new SpellInfo(DoubleValueKey_ManaCBonus, 1.30, 1.30)}, // Legendary Hermetic Link
+ };
+}
diff --git a/src/OpenAC.MosswartMassacre/Inventory/ItemHeaderSource.cs b/src/OpenAC.MosswartMassacre/Inventory/ItemHeaderSource.cs
new file mode 100644
index 0000000..329374a
--- /dev/null
+++ b/src/OpenAC.MosswartMassacre/Inventory/ItemHeaderSource.cs
@@ -0,0 +1,76 @@
+using AcDream.Plugin.Abstractions;
+
+namespace OpenAC.MosswartMassacre.Inventory;
+
+///
+/// The header words the original's key tables carried for every item that
+/// the plugin contract does not hand a plugin yet. Each one that is null is
+/// left out of the item record, which is what the original sent for an item
+/// whose framework lacked the value.
+///
+///
+///
+/// matters beyond its own key: it says which
+/// optional header fields the server sent. While it is null the builder
+/// decides presence from the field's value instead (present when non-zero),
+/// which is wrong for a field the server sent as zero, most often the
+/// equipable-slots word.
+///
+///
+/// The physics words come from the object's physics description, which
+/// the contract does not expose at all.
+///
+///
+internal readonly record struct ItemHeaderWords
+{
+ /// The object header's first flag word (key 218103832).
+ public uint? WeenieHeaderFlags { get; init; }
+
+ /// The object header's second flag word (key 218103833), when the header has one.
+ public uint? WeenieHeaderFlags2 { get; init; }
+
+ /// The physics description's flag word (key 218103847).
+ public uint? PhysicsDescriptionFlags { get; init; }
+
+ /// The physics word the framework filed under key 218103831.
+ public uint? PhysicsWord { get; init; }
+
+ /// The model setup id (key 218103830).
+ public uint? SetupId { get; init; }
+
+ ///
+ /// The value the framework filed under its scale key (167772170). It was
+ /// 0.0 in every recorded item whose physics description carried a scale.
+ ///
+ public double? Scale { get; init; }
+
+ /// The object a wielded item hangs from (key 218103818).
+ public uint? ParentObjectId { get; init; }
+
+ /// Where on its parent a wielded item hangs (key 218103819).
+ public int? ParentLocation { get; init; }
+
+ /// The header's hook type (key 218103828).
+ public int? HookType { get; init; }
+}
+
+/// Reads the header words for one owned item.
+internal interface IItemHeaderSource
+{
+ ItemHeaderWords Read(in PluginInventoryItem item);
+}
+
+///
+/// The contract this plugin builds against exposes none of these words, so
+/// every one is absent. Replace this when the contract grows them.
+///
+internal sealed class HostItemHeaderSource : IItemHeaderSource
+{
+ public static HostItemHeaderSource Instance { get; } = new();
+
+ private HostItemHeaderSource()
+ {
+ }
+
+ public ItemHeaderWords Read(in PluginInventoryItem item) => default;
+}
diff --git a/src/OpenAC.MosswartMassacre/Inventory/MyWorldObject.cs b/src/OpenAC.MosswartMassacre/Inventory/MyWorldObject.cs
new file mode 100644
index 0000000..81d4000
--- /dev/null
+++ b/src/OpenAC.MosswartMassacre/Inventory/MyWorldObject.cs
@@ -0,0 +1,313 @@
+using System.Globalization;
+
+namespace OpenAC.MosswartMassacre.Inventory;
+
+///
+/// One item as the backend receives it: the original plugin's item record,
+/// carried over member for member because it is serialized as-is into
+/// full_inventory and inventory_delta .
+///
+///
+///
+/// Newtonsoft writes the public fields first, in declaration order, then every
+/// public getter, in declaration order. The getters are part of the wire: the
+/// backend reads several of them (Material, EquipSkill, MaxDamage, WieldLevel,
+/// ...). Keep every member, its type, its name and its position exactly as
+/// they are; a double getter must stay a double so -1 is written
+/// -1.0 .
+///
+///
+/// The four tables are keyed the way the original's client framework keyed
+/// them: raw game property ids mixed with that framework's own pseudo keys
+/// (see ). fills them.
+///
+///
+internal sealed class MyWorldObject
+{
+ public bool HasIdData;
+ public int Id;
+ public int LastIdTime;
+ public int ObjectClass;
+ public int Icon;
+ public int ContainerId;
+
+ public Dictionary BoolValues = [];
+ public Dictionary DoubleValues = [];
+ public Dictionary IntValues = [];
+ public Dictionary StringValues = [];
+
+ public List ActiveSpells = [];
+ public List Spells = [];
+
+ ///
+ /// Copies every entry of the given tables over this record's, keeping any
+ /// key only this record has. The original guarded each copy with a check
+ /// against the table it was iterating, which is always true, so every
+ /// incoming entry simply overwrites.
+ ///
+ public void AddTo(
+ IDictionary boolValues,
+ IDictionary doubleValues,
+ IDictionary intValues,
+ IDictionary stringValues)
+ {
+ foreach (KeyValuePair kvp in boolValues)
+ BoolValues[kvp.Key] = kvp.Value;
+ foreach (KeyValuePair kvp in doubleValues)
+ DoubleValues[kvp.Key] = kvp.Value;
+ foreach (KeyValuePair kvp in intValues)
+ IntValues[kvp.Key] = kvp.Value;
+ foreach (KeyValuePair kvp in stringValues)
+ StringValues[kvp.Key] = kvp.Value;
+ }
+
+ public string? Material { get { if (IntValues.ContainsKey(131)) return ItemDictionaries.MaterialInfo.ContainsKey(IntValues[131]) ? ItemDictionaries.MaterialInfo[IntValues[131]] : IntValues[131].ToString(CultureInfo.InvariantCulture); return null; } }
+
+ public string? Name { get { return StringValues.ContainsKey(1) ? StringValues[1] : null; } }
+
+ public string? EquipSkill { get { if (IntValues.ContainsKey(218103840)) return ItemDictionaries.SkillInfo.ContainsKey(IntValues[218103840]) ? ItemDictionaries.SkillInfo[IntValues[218103840]] : IntValues[218103840].ToString(CultureInfo.InvariantCulture); return null; } }
+
+ public string? Mastery { get { if (IntValues.ContainsKey(353)) return ItemDictionaries.MasteryInfo.ContainsKey(IntValues[353]) ? ItemDictionaries.MasteryInfo[IntValues[353]] : IntValues[353].ToString(CultureInfo.InvariantCulture); return null; } }
+
+ public string? ItemSet { get { if (IntValues.ContainsKey(265)) return ItemDictionaries.AttributeSetInfo.ContainsKey(IntValues[265]) ? ItemDictionaries.AttributeSetInfo[IntValues[265]] : IntValues[265].ToString(CultureInfo.InvariantCulture); return null; } }
+
+ public int ArmorLevel { get { return IntValues.ContainsKey(28) ? IntValues[28] : -1; } }
+
+ public string? Imbue
+ {
+ get
+ {
+ if (!IntValues.ContainsKey(179) || IntValues[179] == 0) return null;
+
+ string retVal = string.Empty;
+ if ((IntValues[179] & 1) == 1) retVal += " CS";
+ if ((IntValues[179] & 2) == 2) retVal += " CB";
+ if ((IntValues[179] & 4) == 4) retVal += " AR";
+ if ((IntValues[179] & 8) == 8) retVal += " SlashRend";
+ if ((IntValues[179] & 16) == 16) retVal += " PierceRend";
+ if ((IntValues[179] & 32) == 32) retVal += " BludgeRend";
+ if ((IntValues[179] & 64) == 64) retVal += " AcidRend";
+ if ((IntValues[179] & 128) == 128) retVal += " FrostRend";
+ if ((IntValues[179] & 256) == 256) retVal += " LightRend";
+ if ((IntValues[179] & 512) == 512) retVal += " FireRend";
+ if ((IntValues[179] & 1024) == 1024) retVal += " MeleeImbue";
+ if ((IntValues[179] & 4096) == 4096) retVal += " MagicImbue";
+ if ((IntValues[179] & 8192) == 8192) retVal += " Hematited";
+ if ((IntValues[179] & 536870912) == 536870912) retVal += " MagicAbsorb";
+ retVal = retVal.Trim();
+ return retVal;
+ }
+ }
+
+ public int Tinks { get { return IntValues.ContainsKey(171) ? IntValues[171] : -1; } }
+
+ public int MaxDamage { get { return IntValues.ContainsKey(218103842) ? IntValues[218103842] : -1; } }
+
+ public int ElementalDmgBonus { get { return IntValues.ContainsKey(204) ? IntValues[204] : -1; } }
+
+ public double Variance { get { return DoubleValues.ContainsKey(167772171) ? DoubleValues[167772171] : -1; } }
+
+ public double DamageBonus { get { return DoubleValues.ContainsKey(167772174) ? DoubleValues[167772174] : -1; } }
+
+ public double ElementalDamageVersusMonsters { get { return DoubleValues.ContainsKey(152) ? DoubleValues[152] : -1; } }
+
+ public double AttackBonus { get { return DoubleValues.ContainsKey(167772172) ? DoubleValues[167772172] : -1; } }
+
+ public double MeleeDefenseBonus { get { return DoubleValues.ContainsKey(29) ? DoubleValues[29] : -1; } }
+
+ public double MagicDBonus { get { return DoubleValues.ContainsKey(150) ? DoubleValues[150] : -1; } }
+
+ public double MissileDBonus { get { return DoubleValues.ContainsKey(149) ? DoubleValues[149] : -1; } }
+
+ public double ManaCBonus { get { return DoubleValues.ContainsKey(144) ? DoubleValues[144] : -1; } }
+
+ public int WieldLevel { get { if (IntValues.ContainsKey(160) && IntValues[160] > 0 && IntValues.ContainsKey(158) && IntValues[158] == 7 && IntValues.ContainsKey(159) && IntValues[159] == 1) return IntValues[160]; return -1; } }
+
+ public int SkillLevel { get { if (IntValues.ContainsKey(160) && IntValues[160] > 0 && (!IntValues.ContainsKey(158) || IntValues[158] != 7) && IntValues.ContainsKey(159)) return IntValues[160]; return -1; } }
+
+ public int LoreRequirement { get { return IntValues.ContainsKey(109) ? IntValues[109] : -1; } }
+
+ public double SalvageWorkmanship { get { return DoubleValues.ContainsKey(167772169) ? DoubleValues[167772169] : -1; } }
+
+ public int Workmanship { get { return IntValues.ContainsKey(105) ? IntValues[105] : -1; } }
+
+ public int Value { get { return IntValues.ContainsKey(19) ? IntValues[19] : -1; } }
+
+ public int Burden { get { return IntValues.ContainsKey(5) ? IntValues[5] : -1; } }
+
+ public int DamRating { get { return IntValues.ContainsKey(370) ? IntValues[370] : -1; } }
+
+ public int DamResistRating { get { return IntValues.ContainsKey(371) ? IntValues[371] : -1; } }
+
+ public int CritRating { get { return IntValues.ContainsKey(372) ? IntValues[372] : -1; } }
+
+ public int CritResistRating { get { return IntValues.ContainsKey(373) ? IntValues[373] : -1; } }
+
+ public int CritDamRating { get { return IntValues.ContainsKey(374) ? IntValues[374] : -1; } }
+
+ public int CritDamResistRating { get { return IntValues.ContainsKey(375) ? IntValues[375] : -1; } }
+
+ public int HealBoostRating { get { return IntValues.ContainsKey(376) ? IntValues[376] : -1; } }
+
+ public int VitalityRating { get { return IntValues.ContainsKey(379) ? IntValues[379] : -1; } }
+
+ /// The sum of every rating on the item, or -1 when it carries none.
+ public int TotalRating
+ {
+ get
+ {
+ if (DamRating == -1 && DamResistRating == -1 && CritRating == -1 && CritResistRating == -1 && CritDamRating == -1 && CritDamResistRating == -1 && HealBoostRating == -1 && VitalityRating == -1)
+ return -1;
+
+ return Math.Max(DamRating, 0) + Math.Max(DamResistRating, 0) + Math.Max(CritRating, 0) + Math.Max(CritResistRating, 0) + Math.Max(CritDamRating, 0) + Math.Max(CritDamResistRating, 0) + Math.Max(HealBoostRating, 0) + Math.Max(VitalityRating, 0);
+ }
+ }
+
+ ///
+ /// The armor level less buffs and tinks (20 each, the imbue excluded), plus
+ /// any impenetrability cantrip; a non-loot item (no material) loses 200.
+ ///
+ public int CalcedStartingArmorLevel
+ {
+ get
+ {
+ int armorFromTinks = 0;
+ int armorFromBuffs = 0;
+
+ if (Tinks > 0 && ArmorLevel > 0)
+ armorFromTinks = (Imbue != null) ? (Tinks - 1) * 20 : Tinks * 20;
+
+ if ((!IntValues.ContainsKey(131) || IntValues[131] == 0) && ArmorLevel > 0)
+ armorFromTinks = 200;
+
+ foreach (int spell in ActiveSpells)
+ {
+ foreach (KeyValuePair> effect in ItemDictionaries.LongValueKeySpellEffects)
+ {
+ if (spell == effect.Key && effect.Value.Key == 28)
+ armorFromBuffs += effect.Value.Change;
+ }
+ }
+
+ foreach (int spell in Spells)
+ {
+ foreach (KeyValuePair> effect in ItemDictionaries.LongValueKeySpellEffects)
+ {
+ if (spell == effect.Key && effect.Value.Key == 28)
+ armorFromBuffs -= effect.Value.Bonus;
+ }
+ }
+
+ return ArmorLevel - armorFromTinks - armorFromBuffs;
+ }
+ }
+
+ /// A melee weapon's best damage over time after ten tinks, from its variance, max damage and tinks.
+ public double CalcedBuffedTinkedDoT
+ {
+ get
+ {
+ if (!DoubleValues.ContainsKey(167772171) || !IntValues.ContainsKey(218103842))
+ return -1;
+
+ double variance = DoubleValues.ContainsKey(167772171) ? DoubleValues[167772171] : 0;
+ int maxDamage = GetBuffedIntValueKey(218103842);
+
+ int numberOfTinksLeft = Math.Max(10 - Math.Max(Tinks, 0), 0);
+
+ if (!IntValues.ContainsKey(179) || IntValues[179] == 0)
+ numberOfTinksLeft--;
+
+ if (!IntValues.ContainsKey(131) || IntValues[131] == 0)
+ numberOfTinksLeft = 0;
+
+ for (int i = 1; i <= numberOfTinksLeft; i++)
+ {
+ double ironTinkDoT = CalculateDamageOverTime(maxDamage + 24 + 1, variance);
+ double graniteTinkDoT = CalculateDamageOverTime(maxDamage + 24, variance * .8);
+
+ if (ironTinkDoT >= graniteTinkDoT)
+ maxDamage++;
+ else
+ variance *= .8;
+ }
+
+ return CalculateDamageOverTime(maxDamage + 24, variance);
+ }
+ }
+
+ public double CalcedBuffedMissileDamage { get { if (!IntValues.ContainsKey(218103842) || !DoubleValues.ContainsKey(167772174) || !IntValues.ContainsKey(204)) return -1; return GetBuffedIntValueKey(218103842) + (((GetBuffedDoubleValueKey(167772174) - 1) * 100) / 3) + GetBuffedIntValueKey(204); } }
+
+ public double BuffedElementalDamageVersusMonsters { get { return GetBuffedDoubleValueKey(152, -1); } }
+
+ public double BuffedAttackBonus { get { return GetBuffedDoubleValueKey(167772172, -1); } }
+
+ public double BuffedMeleeDefenseBonus { get { return GetBuffedDoubleValueKey(29, -1); } }
+
+ public double BuffedManaCBonus { get { return GetBuffedDoubleValueKey(144, -1); } }
+
+ public int GetBuffedIntValueKey(int key, int defaultValue = 0)
+ {
+ if (!IntValues.ContainsKey(key))
+ return defaultValue;
+
+ int value = IntValues[key];
+
+ foreach (int spell in ActiveSpells)
+ {
+ if (ItemDictionaries.LongValueKeySpellEffects.ContainsKey(spell) && ItemDictionaries.LongValueKeySpellEffects[spell].Key == key)
+ value -= ItemDictionaries.LongValueKeySpellEffects[spell].Change;
+ }
+
+ foreach (int spell in Spells)
+ {
+ if (ItemDictionaries.LongValueKeySpellEffects.ContainsKey(spell) && ItemDictionaries.LongValueKeySpellEffects[spell].Key == key)
+ value += ItemDictionaries.LongValueKeySpellEffects[spell].Bonus;
+ }
+
+ return value;
+ }
+
+ public double GetBuffedDoubleValueKey(int key, double defaultValue = 0)
+ {
+ if (!DoubleValues.ContainsKey(key))
+ return defaultValue;
+
+ double value = DoubleValues[key];
+
+ foreach (int spell in ActiveSpells)
+ {
+ if (ItemDictionaries.DoubleValueKeySpellEffects.ContainsKey(spell) && ItemDictionaries.DoubleValueKeySpellEffects[spell].Key == key)
+ {
+ if (Math.Abs(ItemDictionaries.DoubleValueKeySpellEffects[spell].Change - 1) < double.Epsilon)
+ value /= ItemDictionaries.DoubleValueKeySpellEffects[spell].Change;
+ else
+ value -= ItemDictionaries.DoubleValueKeySpellEffects[spell].Change;
+ }
+ }
+
+ foreach (int spell in Spells)
+ {
+ if (ItemDictionaries.DoubleValueKeySpellEffects.ContainsKey(spell) && ItemDictionaries.DoubleValueKeySpellEffects[spell].Key == key && Math.Abs(ItemDictionaries.DoubleValueKeySpellEffects[spell].Bonus - 0) > double.Epsilon)
+ {
+ if (Math.Abs(ItemDictionaries.DoubleValueKeySpellEffects[spell].Change - 1) < double.Epsilon)
+ value *= ItemDictionaries.DoubleValueKeySpellEffects[spell].Bonus;
+ else
+ value += ItemDictionaries.DoubleValueKeySpellEffects[spell].Bonus;
+ }
+ }
+
+ return value;
+ }
+
+ /// maxDamage * ((1 - critChance) * (2 - variance) / 2 + (critChance * critMultiplier)).
+ public static double CalculateDamageOverTime(int maxDamage, double variance, double critChance = .1, double critMultiplier = 2)
+ {
+ return maxDamage * ((1 - critChance) * (2 - variance) / 2 + (critChance * critMultiplier));
+ }
+
+ public override string? ToString()
+ {
+ return Name;
+ }
+}
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..a5cabb6
--- /dev/null
+++ b/tests/OpenAC.MosswartMassacre.Tests/Inventory/InventoryFeatureTests.cs
@@ -0,0 +1,273 @@
+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.Plugin.OnLogoff();
+
+ 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"));
+ }
+
+ [Fact]
+ public void The_appraisal_time_is_the_wall_clock_when_the_appraisal_arrived()
+ {
+ using var harness = new PluginHarness("Mossy");
+ InventoryFeature feature = LoginAndSettle(harness);
+ Own(harness.Host, Tachi(appraised: false));
+ Raise(harness.Host, 0x80000202u, PluginObjectChangeKind.Created);
+
+ Own(harness.Host, Tachi(appraised: true), TachiProps(), hostIdTime: 5000);
+ Raise(harness.Host, 0x80000202u, PluginObjectChangeKind.IdentReceived);
+ harness.Clock.Advance(TimeSpan.FromMinutes(3));
+ harness.Frames.Clear();
+ feature.FlushDirtyItems();
+
+ JObject update = JObject.Parse(harness.FramesOfTypeRaw("inventory_delta").Single());
+ Assert.Equal(1790325015, (int)update["item"]!["LastIdTime"]!);
+ }
+}
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();
+}