From f4a44bd9fce02ed341fa22041ad5e8b1e687f325 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 25 Sep 2026 12:36:33 +0200 Subject: [PATCH 1/3] Inventory: the backend's item record and the framework-key rebuild The backend reads items in the shape the original plugin serialized them: its item record's fields and every public getter, with property tables keyed the way the original's client framework keyed them (raw property ids mixed with framework pseudo keys). The record is carried over member for member, with the getters' lookup tables verbatim, so Newtonsoft writes the same members in the same order with the same JSON types. The builder rebuilds the framework keys from what the plugin contract exposes. Which keys exist, and when, follows the original's own item dumps (3058 items): a header field's key is present exactly when the server sent the field, the value key always (-1 when unsent), the underlay key with the second header word, spell counts with a non-empty appraised spell book, weapon and armor keys with their profiles; the slot key never appears. Header words the contract does not expose yet (header flag words, physics words, hook type, parent) sit behind one seam that reports them absent, and while the header flags are absent a field's presence falls back to its value being non-zero. Tables are written in ascending key order: the original's order was its framework's hash-table order and cannot be reproduced; the backend reads by key. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Inventory/DecalItemBuilder.cs | 262 +++++++++++ .../Inventory/DecalKeys.cs | 110 +++++ .../Inventory/ItemDictionaries.cs | 441 ++++++++++++++++++ .../Inventory/ItemHeaderSource.cs | 76 +++ .../Inventory/MyWorldObject.cs | 313 +++++++++++++ 5 files changed, 1202 insertions(+) create mode 100644 src/OpenAC.MosswartMassacre/Inventory/DecalItemBuilder.cs create mode 100644 src/OpenAC.MosswartMassacre/Inventory/DecalKeys.cs create mode 100644 src/OpenAC.MosswartMassacre/Inventory/ItemDictionaries.cs create mode 100644 src/OpenAC.MosswartMassacre/Inventory/ItemHeaderSource.cs create mode 100644 src/OpenAC.MosswartMassacre/Inventory/MyWorldObject.cs 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/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; + } +} From 7a6b0a7f9da976ab1ff7631cc079e887e3f76f46 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 25 Sep 2026 12:39:55 +0200 Subject: [PATCH 2/3] Inventory: full_inventory, inventory_delta, equipment_cantrip_state The backend's inventory views and mana panel are fed by three streams the original sent: the whole inventory after login, at logoff and on /mm sendinventory; an add, update or remove per item as it enters, changes in or leaves the packs; and the per-item cantrip state of worn and wielded gear. This ports all three onto the host's object events, with the frames built exactly as the original built them. Behaviour kept: adds and removes go out at once, changes are coalesced into one update per item on a two-to-five-minute timer re-rolled per flush; the first login appraises everything worth appraising and waits before sending; later logins merge in the saved appraisal data (kept in the character's storage, as the original kept a file per character); the mana panel debounces a quarter second and projects mana down between reports. Adapted to the host: appraisals go through a tick-driven queue, one at a time, retrying while the host's single slot is busy; the login upload waits for the inventory to stop arriving and for the socket, so it is not written into a closed socket; the logoff upload subscribes ahead of the root so the socket is still open; enchantment changes are polled once a second (the host has no event); an item-cast enchantment is the one that never runs out. A request the server never answers no longer holds the upload back for good. The Prismatic Taper count telemetry reads is InventoryFeature.PrismaticTaperCount. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/OpenAC.MosswartMassacre/FeatureCatalog.cs | 10 +- .../Inventory/EquipmentCantripStateFeature.cs | 425 +++++++++++++ .../Inventory/IdentifyQueue.cs | 123 ++++ .../Inventory/InventoryFeature.cs | 602 ++++++++++++++++++ .../Inventory/InventoryReader.cs | 136 ++++ .../Inventory/DecalItemBuilderTests.cs | 114 ++++ .../Inventory/EquipmentCantripStateTests.cs | 95 +++ .../Inventory/IdentifyQueueTests.cs | 88 +++ .../Inventory/InventoryFeatureTests.cs | 255 ++++++++ .../Inventory/InventoryGoldenTests.cs | 155 +++++ .../Inventory/InventoryWorld.cs | 205 ++++++ 11 files changed, 2206 insertions(+), 2 deletions(-) create mode 100644 src/OpenAC.MosswartMassacre/Inventory/EquipmentCantripStateFeature.cs create mode 100644 src/OpenAC.MosswartMassacre/Inventory/IdentifyQueue.cs create mode 100644 src/OpenAC.MosswartMassacre/Inventory/InventoryFeature.cs create mode 100644 src/OpenAC.MosswartMassacre/Inventory/InventoryReader.cs create mode 100644 tests/OpenAC.MosswartMassacre.Tests/Inventory/DecalItemBuilderTests.cs create mode 100644 tests/OpenAC.MosswartMassacre.Tests/Inventory/EquipmentCantripStateTests.cs create mode 100644 tests/OpenAC.MosswartMassacre.Tests/Inventory/IdentifyQueueTests.cs create mode 100644 tests/OpenAC.MosswartMassacre.Tests/Inventory/InventoryFeatureTests.cs create mode 100644 tests/OpenAC.MosswartMassacre.Tests/Inventory/InventoryGoldenTests.cs create mode 100644 tests/OpenAC.MosswartMassacre.Tests/Inventory/InventoryWorld.cs diff --git a/src/OpenAC.MosswartMassacre/FeatureCatalog.cs b/src/OpenAC.MosswartMassacre/FeatureCatalog.cs index 697ab4e..15fbffc 100644 --- a/src/OpenAC.MosswartMassacre/FeatureCatalog.cs +++ b/src/OpenAC.MosswartMassacre/FeatureCatalog.cs @@ -1,4 +1,5 @@ using OpenAC.MosswartMassacre.Backend; +using OpenAC.MosswartMassacre.Inventory; namespace OpenAC.MosswartMassacre; @@ -10,8 +11,13 @@ internal static class FeatureCatalog { public static void AddFeatures(MmContext context, BackendSession session, List features) { - _ = context; _ = session; - _ = features; + + // Inventory: full_inventory, inventory_delta, equipment_cantrip_state, + // and the taper count telemetry reads (InventoryFeature.PrismaticTaperCount). + var inventoryReader = new InventoryReader(context, HostItemHeaderSource.Instance); + var identifyQueue = new IdentifyQueue(() => context.Automation); + features.Add(new InventoryFeature(context, inventoryReader, identifyQueue)); + features.Add(new EquipmentCantripStateFeature(context, inventoryReader)); } } diff --git a/src/OpenAC.MosswartMassacre/Inventory/EquipmentCantripStateFeature.cs b/src/OpenAC.MosswartMassacre/Inventory/EquipmentCantripStateFeature.cs new file mode 100644 index 0000000..d123257 --- /dev/null +++ b/src/OpenAC.MosswartMassacre/Inventory/EquipmentCantripStateFeature.cs @@ -0,0 +1,425 @@ +using AcDream.Plugin.Abstractions; +using OpenAC.MosswartMassacre.Backend; + +namespace OpenAC.MosswartMassacre.Inventory; + +/// +/// The backend's mana panel: for every spell-bearing item the character +/// wears or wields, whether its spells are running, its mana and how long +/// that mana lasts, sent as equipment_cantrip_state a quarter second +/// after the last relevant change. +/// +/// +/// +/// An item is unknown until appraised, not_activatable (and left +/// out) without spells or mana, not_active when out of mana or when +/// one of its beneficial spells is neither running on the item nor matched +/// by an equal-or-stronger item spell on the character, and active +/// otherwise. The mana of an active item is projected down from the moment +/// its mana was last reported, at one point per burn interval. +/// +/// +/// The item is read through its item record, so every value is taken from +/// the same keys the original read. The host raises no event when the +/// character's enchantments change, so they are compared once a second. +/// +/// +internal sealed class EquipmentCantripStateFeature : IMmFeature +{ + public static readonly TimeSpan Debounce = TimeSpan.FromMilliseconds(250); + public static readonly TimeSpan EnchantmentPoll = TimeSpan.FromSeconds(1); + + private const int CloakSlot = 134217728; + private const int AmmoSlot = 8388608; + + private readonly MmContext _context; + private readonly InventoryReader _reader; + private readonly Action _onObjectChanged; + private readonly Action _onTick; + private readonly Dictionary _lastManaUpdateByItemId = []; + private readonly Dictionary _lastSeenMana = []; + private IDisposable? _debounce; + private bool _active; + private double _enchantmentPollSeconds; + private string _enchantmentSignature = string.Empty; + + public EquipmentCantripStateFeature(MmContext context, InventoryReader reader) + { + _context = context; + _reader = reader; + _onObjectChanged = OnObjectChanged; + _onTick = OnTick; + _context.Host.Events.ObjectChanged += _onObjectChanged; + _context.Host.Events.Tick += _onTick; + } + + public void OnLogin() + { + Cleanup(); + _active = true; + try + { + foreach (PluginInventoryItem item in _reader.CaptureOwned()) + RememberManaSnapshotTime(item.ObjectId); + _enchantmentSignature = EnchantmentSignature(); + RequestRefresh(); + } + catch (Exception ex) + { + _context.Host.Log.Warn($"[EquipCantrip] Initialize error: {ex.Message}"); + } + } + + public void OnLogoff() + { + _active = false; + Cleanup(); + } + + public void Dispose() + { + _context.Host.Events.ObjectChanged -= _onObjectChanged; + _context.Host.Events.Tick -= _onTick; + Cleanup(); + } + + private void Cleanup() + { + _debounce?.Dispose(); + _debounce = null; + _lastManaUpdateByItemId.Clear(); + _lastSeenMana.Clear(); + _enchantmentPollSeconds = 0d; + _enchantmentSignature = string.Empty; + } + + private void OnTick(double deltaSeconds) + { + if (!_active) + return; + _enchantmentPollSeconds += deltaSeconds; + if (_enchantmentPollSeconds < EnchantmentPoll.TotalSeconds) + return; + _enchantmentPollSeconds = 0d; + string signature = EnchantmentSignature(); + if (signature != _enchantmentSignature) + { + _enchantmentSignature = signature; + RequestRefresh(); + } + } + + private string EnchantmentSignature() => + string.Join(',', _context.Automation.Character.ActiveEnchantments.Select(e => e.SpellId).Order()); + + private void OnObjectChanged(PluginObjectChange change) + { + if (!_active) + return; + try + { + switch (change.Kind) + { + case PluginObjectChangeKind.Created: + if (change.Current is { } created && IsPotentiallyRelevant(created)) + { + RememberManaSnapshotTime(created.ObjectId); + RequestRefresh(); + } + break; + + case PluginObjectChangeKind.Released: + bool wasTracked = _lastManaUpdateByItemId.Remove(change.ObjectId); + _lastSeenMana.Remove(change.ObjectId); + if (wasTracked || (change.Current is { } released && IsPotentiallyRelevant(released))) + RequestRefresh(); + break; + + default: + if (change.Current is not { } changed) + break; + bool manaChanged = IsPotentiallyRelevant(changed) && ManaChanged(changed.ObjectId); + if (change.Kind == PluginObjectChangeKind.IdentReceived || manaChanged) + RememberManaSnapshotTime(changed.ObjectId); + if (IsPotentiallyRelevant(changed)) + RequestRefresh(); + break; + } + } + catch (Exception ex) + { + _context.Host.Log.Warn($"[EquipCantrip] Change error: {ex.Message}"); + } + } + + /// The host has no mana-change event; a changed current-mana value is one. + private bool ManaChanged(uint objectId) + { + int mana = CurrentMana(objectId); + return _lastSeenMana.TryGetValue(objectId, out int previous) && previous != mana; + } + + private int CurrentMana(uint objectId) => + _context.Automation.Items.TryCaptureProperties(objectId, out PluginItemProperties properties) + && properties.Ints is { } ints + && ints.TryGetValue(DecalKeys.RawCurrentMana, out int mana) + ? mana + : 0; + + private void RememberManaSnapshotTime(uint objectId) + { + _lastManaUpdateByItemId[objectId] = _context.Clock.UtcNow; + _lastSeenMana[objectId] = CurrentMana(objectId); + } + + private bool IsPotentiallyRelevant(PluginWorldObject wo) => + (wo.WielderObjectId != 0u && wo.WielderObjectId == _reader.CharacterId) + || _lastManaUpdateByItemId.ContainsKey(wo.ObjectId); + + private void RequestRefresh() + { + _debounce?.Dispose(); + _debounce = _context.Scheduler.Delay(Debounce, SendSnapshot); + } + + internal void SendSnapshot() + { + _debounce = null; + try + { + if (!_active || !_context.Settings.Current.WebSocketEnabled) + return; + + object[] items = BuildSnapshotItems().ToArray(); + _context.Send(new + { + type = "equipment_cantrip_state", + timestamp = Wire.Timestamp(_context.Clock), + character_name = _context.CharacterName, + items = items, + }); + } + catch (Exception ex) + { + _context.Host.Log.Warn($"[EquipCantrip] Send error: {ex.Message}"); + } + } + + private List BuildSnapshotItems() + { + var snapshotItems = new List(); + List activeCharacterSpells = GetActiveCharacterItemSpellData(); + foreach (PluginInventoryItem item in _reader.CaptureOwned()) + { + if (item.EquippedLocation == 0u) + continue; + MyWorldObject wo = _reader.Build(item); + if (!ShouldDisplayInManaPanel(wo, item)) + continue; + object? snapshot = BuildItemSnapshot(wo, activeCharacterSpells); + if (snapshot is not null) + snapshotItems.Add(snapshot); + } + return snapshotItems; + } + + /// + /// The character's item-cast enchantments, the ones with no end. The host + /// reports those as never running out. + /// + private List GetActiveCharacterItemSpellData() + { + var activeSpells = new List(); + foreach (PluginActiveEnchantment enchantment in _context.Automation.Character.ActiveEnchantments) + { + if (!double.IsPositiveInfinity(enchantment.SecondsRemaining) && enchantment.SecondsRemaining > 0d) + continue; + if (_context.Automation.Spells.TryGet(enchantment.SpellId, out PluginSpellInfo spell)) + activeSpells.Add(spell); + } + return activeSpells; + } + + private object? BuildItemSnapshot(MyWorldObject wo, List activeCharacterSpells) + { + ItemState itemState = RecalculateState(wo, activeCharacterSpells); + if (itemState == ItemState.NotActivatable) + return null; + + int maximumMana = Int(wo, DecalKeys.RawMaximumMana, 0); + int currentMana = itemState == ItemState.Unknown ? 0 : CalculateCurrentMana(wo, itemState); + double? manaTimeRemainingSeconds = null; + + if (itemState == ItemState.Active) + { + TimeSpan remaining = CalculateManaTimeRemaining(wo, currentMana); + manaTimeRemainingSeconds = remaining.TotalSeconds; + } + + return new + { + item_id = wo.Id, + state = ToWireState(itemState), + current_mana = itemState == ItemState.Unknown ? (int?)null : currentMana, + max_mana = itemState == ItemState.Unknown ? (int?)null : maximumMana, + mana_time_remaining_seconds = manaTimeRemainingSeconds, + }; + } + + private ItemState RecalculateState(MyWorldObject wo, List activeCharacterSpells) + { + if (!wo.HasIdData) + return ItemState.Unknown; + + if (Int(wo, DecalKeys.SpellCount, 0) == 0 || Int(wo, DecalKeys.RawMaximumMana, 0) == 0) + return ItemState.NotActivatable; + + if (Int(wo, DecalKeys.RawCurrentMana, 0) == 0) + return ItemState.NotActive; + + foreach (int spellOnItemId in wo.Spells) + { + if (wo.IntValues.TryGetValue(DecalKeys.SpellDid, out int associated) && associated == spellOnItemId) + continue; + + if (!_context.Automation.Spells.TryGet(unchecked((uint)spellOnItemId), out PluginSpellInfo spellOnItem)) + continue; + + if (spellOnItem.IsDebuff || spellOnItem.IsOffensive) + continue; + + bool thisSpellIsActive = false; + foreach (int activeSpellId in wo.ActiveSpells) + { + if (_context.Automation.Spells.TryGet(unchecked((uint)activeSpellId), out PluginSpellInfo activeSpellOnItem) + && SpellMatchesOrSurpasses(activeSpellOnItem, spellOnItem)) + { + thisSpellIsActive = true; + break; + } + } + + if (thisSpellIsActive) + continue; + + foreach (PluginSpellInfo activeSpellOnChar in activeCharacterSpells) + { + if (SpellMatchesOrSurpasses(activeSpellOnChar, spellOnItem)) + { + thisSpellIsActive = true; + break; + } + } + + if (!thisSpellIsActive) + return ItemState.NotActive; + } + + return ItemState.Active; + } + + private static bool SpellMatchesOrSurpasses(PluginSpellInfo activeSpell, PluginSpellInfo requiredSpell) => + activeSpell.Family == requiredSpell.Family && activeSpell.Difficulty >= requiredSpell.Difficulty; + + private int CalculateCurrentMana(MyWorldObject wo, ItemState itemState) + { + if (itemState == ItemState.Unknown || itemState == ItemState.NotActivatable) + return 0; + + if (itemState == ItemState.NotActive) + return Int(wo, DecalKeys.RawCurrentMana, 0); + + double secondsPerBurn = GetSecondsPerBurn(wo); + if (secondsPerBurn <= 0) + return Int(wo, DecalKeys.RawCurrentMana, 0); + + uint objectId = unchecked((uint)wo.Id); + DateTime now = _context.Clock.UtcNow; + if (!_lastManaUpdateByItemId.TryGetValue(objectId, out DateTime lastUpdate)) + { + lastUpdate = now; + _lastManaUpdateByItemId[objectId] = lastUpdate; + } + + TimeSpan timeSinceLastUpdate = now - lastUpdate; + int burnedMana = (int)(timeSinceLastUpdate.TotalSeconds / secondsPerBurn); + int currentMana = Int(wo, DecalKeys.RawCurrentMana, 0); + if (burnedMana > currentMana) + burnedMana = currentMana; + + return Math.Max(currentMana - burnedMana, 0); + } + + private static TimeSpan CalculateManaTimeRemaining(MyWorldObject wo, int calculatedCurrentMana) + { + double secondsPerBurn = GetSecondsPerBurn(wo); + if (secondsPerBurn <= 0) + return new TimeSpan(99, 99, 0); + + return TimeSpan.FromSeconds(calculatedCurrentMana * secondsPerBurn); + } + + private static double GetSecondsPerBurn(MyWorldObject wo) + { + if (!wo.HasIdData) + return 0; + + double manaRateOfChange = wo.DoubleValues.TryGetValue(DecalKeys.RawManaRate, out double rate) ? rate : 0; + if (manaRateOfChange == 0) + return 0; + + return ((int)Math.Ceiling(-0.2 / manaRateOfChange)) * 5; + } + + private bool ShouldDisplayInManaPanel(MyWorldObject wo, in PluginInventoryItem item) + { + if (!ItemIsEquippedByMe(wo, item)) + return false; + + if (!string.IsNullOrEmpty(wo.Name) && wo.Name.Contains("Aetheria", StringComparison.OrdinalIgnoreCase)) + return false; + + if (Int(wo, DecalKeys.EquipableSlots, 0) == CloakSlot) + return false; + + if (Int(wo, DecalKeys.RawEquippedSlots, 0) == AmmoSlot) + return false; + + return true; + } + + /// + /// Worn or wielded by this character. The framework's slot key read -1 + /// for anything not in a pack slot, and then the container decided. + /// + private bool ItemIsEquippedByMe(MyWorldObject wo, in PluginInventoryItem item) + { + int equippedSlots = Int(wo, DecalKeys.RawEquippedSlots, 0); + if (equippedSlots <= 0) + return false; + + if (item.ContainerSlot == -1) + return wo.ContainerId == unchecked((int)_reader.CharacterId); + + return true; + } + + private static int Int(MyWorldObject wo, int key, int defaultValue) => + wo.IntValues.TryGetValue(key, out int value) ? value : defaultValue; + + private static string ToWireState(ItemState itemState) => itemState switch + { + ItemState.Active => "active", + ItemState.NotActive => "not_active", + ItemState.NotActivatable => "not_activatable", + _ => "unknown", + }; + + private enum ItemState + { + Unknown, + NotActivatable, + Active, + NotActive, + } +} diff --git a/src/OpenAC.MosswartMassacre/Inventory/IdentifyQueue.cs b/src/OpenAC.MosswartMassacre/Inventory/IdentifyQueue.cs new file mode 100644 index 0000000..448ec64 --- /dev/null +++ b/src/OpenAC.MosswartMassacre/Inventory/IdentifyQueue.cs @@ -0,0 +1,123 @@ +using AcDream.Plugin.Abstractions; + +namespace OpenAC.MosswartMassacre.Inventory; + +/// +/// Appraises the player's items one at a time. The original fired an +/// appraisal request for every item at once and its framework queued them; +/// the host here has a single appraisal slot and answers +/// while it is taken, so the queue +/// asks again on a later tick instead. +/// +/// +/// A request is finished when the host reports it answered, gave up on it, +/// or when it has been outstanding for . The owner +/// pumps the queue from the tick; nothing here runs on another thread. +/// +internal sealed class IdentifyQueue +{ + /// How long one request may stay unanswered before it is given up. + public static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10); + + private readonly Func _automation; + private readonly LinkedList _pending = new(); + private readonly HashSet _queued = []; + private uint _inFlight; + private double _inFlightSeconds; + private long _appraisalRevisionAtRequest; + + public IdentifyQueue(Func automation) + { + _automation = automation; + } + + /// Raised with the item id and whether the appraisal arrived, once per finished request. + public event Action? Finished; + + /// True when nothing is waiting and nothing is outstanding. + public bool IsIdle => _inFlight == 0u && _pending.Count == 0; + + /// Requests queued or outstanding. + public int Count => _pending.Count + (_inFlight != 0u ? 1 : 0); + + /// Queues one item unless it is already queued or outstanding. + public void Enqueue(uint objectId) + { + if (objectId == 0u || objectId == _inFlight || !_queued.Add(objectId)) + return; + _pending.AddLast(objectId); + } + + /// Drops everything, for a logoff. + public void Clear() + { + _pending.Clear(); + _queued.Clear(); + _inFlight = 0u; + _inFlightSeconds = 0d; + } + + /// The host delivered appraisal data for an object. + public void OnIdentReceived(uint objectId) + { + if (objectId != 0u && objectId == _inFlight) + Finish(success: true); + } + + /// Advances the queue; call once per tick. + public void Pump(double deltaSeconds) + { + if (_inFlight != 0u) + { + _inFlightSeconds += Math.Max(0d, deltaSeconds); + PluginAppraisalState state = _automation().Loot.Appraisal; + if (state.Revision != _appraisalRevisionAtRequest) + { + if (state.CurrentObjectId == _inFlight) + { + Finish(success: true); + } + else if (state.LastAbandonedObjectId == _inFlight) + { + Finish(success: false); + } + } + if (_inFlight != 0u && _inFlightSeconds >= Timeout.TotalSeconds) + Finish(success: false); + if (_inFlight != 0u) + return; + } + + while (_pending.First is { } next) + { + uint objectId = next.Value; + IAutomationSurface automation = _automation(); + PluginItemCommandResult result = automation.Objects.Identify(objectId); + if (result.Status == PluginItemCommandStatus.Busy) + return; + + _pending.RemoveFirst(); + _queued.Remove(objectId); + if (result.Status == PluginItemCommandStatus.Started) + { + _inFlight = objectId; + _inFlightSeconds = 0d; + // Read after the request, so the request's own change to the + // slot is not taken for its answer. + _appraisalRevisionAtRequest = automation.Loot.Appraisal.Revision; + return; + } + + // Refused outright: the item is gone or cannot be appraised. + Finished?.Invoke(objectId, false); + } + } + + private void Finish(bool success) + { + uint objectId = _inFlight; + _inFlight = 0u; + _inFlightSeconds = 0d; + Finished?.Invoke(objectId, success); + } +} diff --git a/src/OpenAC.MosswartMassacre/Inventory/InventoryFeature.cs b/src/OpenAC.MosswartMassacre/Inventory/InventoryFeature.cs new file mode 100644 index 0000000..f30b222 --- /dev/null +++ b/src/OpenAC.MosswartMassacre/Inventory/InventoryFeature.cs @@ -0,0 +1,602 @@ +using AcDream.Plugin.Abstractions; +using Newtonsoft.Json; +using OpenAC.MosswartMassacre.Backend; + +namespace OpenAC.MosswartMassacre.Inventory; + +/// +/// The player's inventory on the backend: the whole inventory +/// (full_inventory) after login, at logoff and on +/// /mm sendinventory; every item entering, changing in or leaving it +/// (inventory_delta); and the Prismatic Taper count the telemetry +/// frame carries. +/// +/// +/// +/// Adds and removes go out at once. Changes to an item already tracked are +/// coalesced and sent as one update per changed item on a randomized +/// two-to-five-minute timer, re-rolled after every flush so a fleet of +/// clients never flushes in step. +/// +/// +/// Login waits until the inventory has stopped arriving (no new owned item +/// for ) and, while streaming, for the socket, so +/// the login inventory is not written into a closed socket. Until then new +/// items are tracked silently, as the original's login stream arrived before +/// its socket was open. Items worth appraising are appraised one at a time +/// through ; the original asked for all of them at +/// once. +/// +/// +/// A copy of the last inventory sent is kept in the character's storage, as +/// the original kept a file per character. It decides whether a login first +/// appraises everything and waits, and it keeps appraisal data from an +/// earlier session for items not yet appraised in this one. +/// +/// +internal sealed class InventoryFeature : IMmFeature +{ + public const string CacheKey = "inventory.json"; + + /// How long no new owned item must arrive before login counts the inventory as complete. + public static readonly TimeSpan SettleQuiet = TimeSpan.FromSeconds(3); + + /// The longest login waits for the inventory to stop arriving. + public static readonly TimeSpan SettleLimit = TimeSpan.FromSeconds(20); + + /// The longest the login inventory waits for the socket before it is written anyway. + public static readonly TimeSpan ConnectLimit = TimeSpan.FromSeconds(30); + + public const int FlushBaseMs = 120000; + public const int FlushJitterMs = 180000; + + private readonly MmContext _context; + private readonly InventoryReader _reader; + private readonly IdentifyQueue _identify; + private readonly Random _rng; + private readonly Action _onObjectChanged; + private readonly Action _onTick; + private readonly Action _onHostLogoff; + + // Live tracking (the original's live inventory tracker). + private readonly HashSet _tracked = []; + private readonly HashSet _dirty = []; + private IDisposable? _flushTimer; + + // Whole-inventory uploads (the original's inventory logger). + private readonly HashSet _requested = []; + private bool _waitingForLoginIds; + private bool _waitingForForcedIds; + + private bool _active; + private bool _settled; + private bool _loginUploadPending; + private double _quietSeconds; + private double _settleSeconds; + private double _connectSeconds; + + private bool _taperDirty = true; + private int _taperCount; + + public InventoryFeature(MmContext context, InventoryReader reader, IdentifyQueue identify, Random? rng = null) + { + _context = context; + _reader = reader; + _identify = identify; + _rng = rng ?? new Random(); + IsBackendReady = () => context.Backend.IsConnected; + _onObjectChanged = OnObjectChanged; + _onTick = OnTick; + _onHostLogoff = OnHostLogoff; + _context.Host.Events.ObjectChanged += _onObjectChanged; + _context.Host.Events.Tick += _onTick; + // Subscribed while the root builds its features, which is before the + // root subscribes its own logoff; so this runs while the socket is + // still open, and the logoff inventory can still be sent. + _context.Host.Events.Logoff += _onHostLogoff; + + context.Commands.Register("sendinventory", _ => ForceInventoryUpload(), "Force full inventory upload"); + } + + /// + /// The Prismatic Tapers in the packs, for the telemetry frame's + /// prismatic_taper_count. Read it on the tick thread; it recounts + /// only after an item changed. + /// + public int PrismaticTaperCount + { + get + { + if (_active && _taperDirty) + { + _taperCount = _reader.CountPrismaticTapers(); + _taperDirty = false; + } + return _taperCount; + } + } + + /// True when the backend can take the login inventory. Tests replace it. + internal Func IsBackendReady { get; set; } + + internal bool IsSettled => _settled; + + internal int TrackedCount => _tracked.Count; + + internal int DirtyCount => _dirty.Count; + + public void OnLogin() + { + ResetState(); + _active = true; + _reader.Reset(); + _taperDirty = true; + + // The original's item events were wired before login, so every item + // of the login stream that was worth appraising was asked for. + foreach (PluginInventoryItem item in _reader.CaptureOwned()) + { + _tracked.Add(item.ObjectId); + if (InventoryReader.NeedsIdent(item.ObjectClass, item.Name) && !_reader.IsIdentified(item.ObjectId)) + _identify.Enqueue(item.ObjectId); + } + } + + public void OnLogoff() + { + _active = false; + ResetState(); + } + + public void Dispose() + { + _context.Host.Events.ObjectChanged -= _onObjectChanged; + _context.Host.Events.Tick -= _onTick; + _context.Host.Events.Logoff -= _onHostLogoff; + ResetState(); + } + + private void ResetState() + { + _flushTimer?.Dispose(); + _flushTimer = null; + _tracked.Clear(); + _dirty.Clear(); + _requested.Clear(); + _identify.Clear(); + _waitingForLoginIds = false; + _waitingForForcedIds = false; + _settled = false; + _loginUploadPending = false; + _quietSeconds = 0d; + _settleSeconds = 0d; + _connectSeconds = 0d; + } + + private bool InventoryLog => _context.Settings.Current.InventoryLog; + + private void OnTick(double deltaSeconds) + { + if (!_active) + return; + + _identify.Pump(deltaSeconds); + + if (!_settled) + { + _quietSeconds += deltaSeconds; + _settleSeconds += deltaSeconds; + if (_quietSeconds >= SettleQuiet.TotalSeconds || _settleSeconds >= SettleLimit.TotalSeconds) + Settle(); + return; + } + + if (_loginUploadPending) + { + _connectSeconds += deltaSeconds; + if (!_context.Settings.Current.WebSocketEnabled || IsBackendReady() || _connectSeconds >= ConnectLimit.TotalSeconds) + { + _loginUploadPending = false; + RunLoginUpload(); + } + return; + } + + if ((_waitingForLoginIds || _waitingForForcedIds) && _identify.IsIdle) + CompleteWaitingUpload(); + } + + /// The inventory has arrived: start live tracking and the login upload. + private void Settle() + { + _settled = true; + foreach (PluginInventoryItem item in _reader.CaptureOwned()) + _tracked.Add(item.ObjectId); + ArmFlushTimer(); + _loginUploadPending = true; + _connectSeconds = 0d; + } + + /// The original's inventory logger at login. + private void RunLoginUpload() + { + if (!InventoryLog) + return; + + if (ReadCache() is null) + { + _context.Chat.Write("Requesting id information for all armor/weapon inventory. This will take a few minutes..."); + foreach (PluginInventoryItem item in _reader.CaptureOwned()) + { + if (!_reader.IsIdentified(item.ObjectId) && InventoryReader.NeedsIdent(item.ObjectClass, item.Name)) + _identify.Enqueue(item.ObjectId); + } + _waitingForLoginIds = true; + } + else + { + DumpInventory(requestIdsIfMissing: true); + } + } + + /// + /// Every appraisal asked for has finished. The original waited until + /// every item worth appraising had been appraised, so one item the server + /// would not appraise held the upload back for good; this sends once the + /// requests have all been answered or given up. + /// + private void CompleteWaitingUpload() + { + bool wasForceUpload = _waitingForForcedIds; + _waitingForLoginIds = false; + _waitingForForcedIds = false; + DumpInventory(); + if (wasForceUpload) + _context.Chat.Write("[INV] All items identified. Inventory upload completed."); + else + _context.Chat.Write("Requesting id information for all armor/weapon inventory completed. Log file written."); + } + + /// /mm sendinventory: appraise what needs it, wait, then send everything. + internal void ForceInventoryUpload() + { + try + { + if (!InventoryLog) + { + _context.Chat.Write("[INV] Inventory logging is disabled"); + return; + } + + if (!_context.Settings.Current.WebSocketEnabled) + { + _context.Chat.Write("[INV] WebSocket streaming is disabled"); + return; + } + + if (!_active) + { + _context.Chat.Write("[INV] Inventory system not initialized"); + return; + } + + int needsId = 0; + int totalItems = 0; + foreach (PluginInventoryItem item in _reader.CaptureOwned()) + { + totalItems++; + if (!_reader.IsIdentified(item.ObjectId) && InventoryReader.NeedsIdent(item.ObjectClass, item.Name)) + { + _identify.Enqueue(item.ObjectId); + needsId++; + } + } + + if (needsId > 0) + { + _waitingForForcedIds = true; + _context.Chat.Write($"[INV] Scanning {totalItems} items, requesting ID for {needsId}. Waiting for identification..."); + } + else + { + _context.Chat.Write($"[INV] All {totalItems} items already identified. Sending..."); + DumpInventory(); + _context.Chat.Write("[INV] Inventory upload completed"); + } + } + catch (Exception ex) + { + _context.Chat.Write($"[INV] Force upload failed: {ex.Message}"); + } + } + + private void OnHostLogoff() + { + if (!_active || !InventoryLog) + return; + try + { + DumpInventory(); + } + catch (Exception ex) + { + _context.Chat.Write($"[INV] {ex}"); + } + } + + /// + /// The original's inventory dump: every owned item, merged with what the + /// saved copy knew about it, saved, and sent as full_inventory. + /// + internal void DumpInventory(bool requestIdsIfMissing = false) + { + List previouslySaved = ReadCache() ?? []; + + var currentList = new List(); + foreach (PluginInventoryItem item in _reader.CaptureOwned()) + { + MyWorldObject current = _reader.Build(item); + bool needsId = requestIdsIfMissing + && !current.HasIdData + && InventoryReader.NeedsIdent(item.ObjectClass, item.Name); + + MyWorldObject? previous = previouslySaved.Find( + prev => prev.Id == current.Id && prev.ObjectClass == current.ObjectClass); + if (previous is not null) + { + if (needsId && !previous.HasIdData) + { + _identify.Enqueue(item.ObjectId); + currentList.Add(current); + } + else + { + currentList.Add(DecalItemBuilder.Combine(previous, current)); + } + continue; + } + + if (needsId) + _identify.Enqueue(item.ObjectId); + currentList.Add(current); + } + + WriteCache(currentList); + + if (_context.Settings.Current.WebSocketEnabled) + { + _context.Send(new + { + type = "full_inventory", + timestamp = Wire.Timestamp(_context.Clock), + character_name = _context.CharacterName, + item_count = currentList.Count, + items = currentList, + }); + _context.Chat.Write("Inventory sent to MosswartOverlord"); + } + } + + private List? ReadCache() + { + IPluginStorage? scope = CacheScope(); + string? text = scope?.ReadText(CacheKey); + if (text is null) + return null; + try + { + return JsonConvert.DeserializeObject>(text) ?? []; + } + catch (JsonException) + { + _context.Chat.Write("Inventory file is corrupt."); + return []; + } + } + + private void WriteCache(List items) + { + IPluginStorage? scope = CacheScope(); + if (scope is null) + return; + try + { + scope.WriteText(CacheKey, JsonConvert.SerializeObject(items, Formatting.Indented)); + } + catch (Exception ex) + { + _context.Host.Log.Warn($"The inventory copy could not be saved: {ex.Message}"); + } + } + + private IPluginStorage? CacheScope() + { + IPluginStorage storage = _context.Host.Storage; + return storage.IsAvailable + ? storage.OpenScope(PluginStorageScope.Character(_context.CharacterName)) + : null; + } + + private void OnObjectChanged(PluginObjectChange change) + { + if (!_active) + return; + try + { + if (change.Kind == PluginObjectChangeKind.IdentReceived) + _identify.OnIdentReceived(change.ObjectId); + + PluginWorldObject? current = change.Current; + if (change.Kind == PluginObjectChangeKind.Released + || current is null + || string.Equals(current.Value.Name, InventoryReader.PrismaticTaper, StringComparison.OrdinalIgnoreCase)) + { + _taperDirty = true; + } + + if (change.Kind == PluginObjectChangeKind.Released) + { + OnRelease(change.ObjectId); + return; + } + if (current is not { } wo) + return; + + if (!_settled) + { + OnLoginStreamItem(wo); + return; + } + + if (change.Kind == PluginObjectChangeKind.Created) + OnCreate(wo); + else + OnChange(wo); + + RequestIdForMainPackItem(wo); + } + catch (Exception ex) + { + _context.Host.Log.Warn($"[LiveInv] Error handling an item change: {ex.Message}"); + } + } + + /// An item of the login stream: tracked without an add, appraised if worth it. + private void OnLoginStreamItem(PluginWorldObject wo) + { + if (!_reader.IsPlayerInventory(wo.ContainerObjectId, wo.WielderObjectId)) + return; + if (_tracked.Add(wo.ObjectId)) + _quietSeconds = 0d; + if (!IsIdentified(wo) && InventoryReader.NeedsIdent(wo.ObjectClass, wo.Name)) + _identify.Enqueue(wo.ObjectId); + } + + private void OnCreate(PluginWorldObject wo) + { + if (!_reader.IsPlayerInventory(wo.ContainerObjectId, wo.WielderObjectId)) + return; + if (_tracked.Contains(wo.ObjectId)) + return; + AddTracked(wo); + } + + private void OnChange(PluginWorldObject wo) + { + if (!_reader.IsPlayerInventory(wo.ContainerObjectId, wo.WielderObjectId)) + { + // The item left the inventory. + if (_tracked.Remove(wo.ObjectId)) + SendRemove(wo.ObjectId); + return; + } + + if (!_tracked.Contains(wo.ObjectId)) + AddTracked(wo); + else + _dirty.Add(wo.ObjectId); + } + + private void OnRelease(uint objectId) + { + if (!_settled) + { + _tracked.Remove(objectId); + return; + } + if (_tracked.Remove(objectId)) + SendRemove(objectId); + } + + private void AddTracked(PluginWorldObject wo) + { + _tracked.Add(wo.ObjectId); + if (InventoryReader.TryFind(_reader.CaptureOwned(), wo.ObjectId, out PluginInventoryItem item)) + SendDelta("add", _reader.Build(item)); + + if (!IsIdentified(wo) && InventoryReader.NeedsIdent(wo.ObjectClass, wo.Name)) + _identify.Enqueue(wo.ObjectId); + } + + /// + /// The inventory logger's own request: an unappraised item that lands in + /// the main pack (or is wielded) is appraised once. + /// + private void RequestIdForMainPackItem(PluginWorldObject wo) + { + if (!InventoryLog || _waitingForLoginIds || _waitingForForcedIds) + return; + uint container = wo.ContainerObjectId != 0u ? wo.ContainerObjectId : wo.WielderObjectId; + if (!IsIdentified(wo) + && InventoryReader.NeedsIdent(wo.ObjectClass, wo.Name) + && container == _reader.CharacterId + && _requested.Add(wo.ObjectId)) + { + _identify.Enqueue(wo.ObjectId); + } + } + + private static bool IsIdentified(PluginWorldObject wo) => wo.HasAppraisalData && wo.LastIdTime != 0; + + private void ArmFlushTimer() + { + _flushTimer?.Dispose(); + _flushTimer = _context.Scheduler.Delay(TimeSpan.FromMilliseconds(NextFlushIntervalMs()), FlushDirtyItems); + } + + /// Two minutes plus up to three more, re-rolled for every flush. + internal int NextFlushIntervalMs() => FlushBaseMs + _rng.Next(0, FlushJitterMs + 1); + + /// Sends the current state of every item that changed since the last flush. + internal void FlushDirtyItems() + { + if (!_active) + return; + ArmFlushTimer(); + if (_dirty.Count == 0) + return; + + var ids = new List(_dirty); + _dirty.Clear(); + IReadOnlyList owned = _reader.CaptureOwned(); + foreach (uint id in ids) + { + try + { + // An item that left since it was marked has had its remove. + if (!_tracked.Contains(id)) + continue; + if (!InventoryReader.TryFind(owned, id, out PluginInventoryItem item) + || !_reader.IsPlayerInventory(item.ContainerObjectId, item.WielderObjectId)) + { + continue; + } + SendDelta("update", _reader.Build(item)); + } + catch (Exception ex) + { + _context.Host.Log.Warn($"[LiveInv] Error flushing item {id}: {ex.Message}"); + } + } + } + + private void SendDelta(string action, MyWorldObject item) => + _context.Send(new + { + type = "inventory_delta", + timestamp = Wire.Timestamp(_context.Clock), + character_name = _context.CharacterName, + action = action, + item = item, + }); + + private void SendRemove(uint objectId) => + _context.Send(new + { + type = "inventory_delta", + timestamp = Wire.Timestamp(_context.Clock), + character_name = _context.CharacterName, + action = "remove", + item_id = unchecked((int)objectId), + }); +} diff --git a/src/OpenAC.MosswartMassacre/Inventory/InventoryReader.cs b/src/OpenAC.MosswartMassacre/Inventory/InventoryReader.cs new file mode 100644 index 0000000..032c573 --- /dev/null +++ b/src/OpenAC.MosswartMassacre/Inventory/InventoryReader.cs @@ -0,0 +1,136 @@ +using AcDream.Plugin.Abstractions; + +namespace OpenAC.MosswartMassacre.Inventory; + +/// +/// Reads the player's items from the host and turns them into item records. +/// Tick thread only, like every host read. +/// +/// +/// The original's appraisal time was the wall clock in Unix seconds when the +/// appraisal arrived. The host keeps its own session clock instead, so the +/// reader stamps the wall clock the first time it sees each new appraisal of +/// an item and reports that stamp from then on. +/// +internal sealed class InventoryReader +{ + public const string PrismaticTaper = "Prismatic Taper"; + + private readonly MmContext _context; + private readonly IItemHeaderSource _headers; + private readonly Dictionary _idStamps = []; + + public InventoryReader(MmContext context, IItemHeaderSource headers) + { + _context = context; + _headers = headers; + } + + public uint CharacterId => _context.Automation.Character.ObjectId; + + /// Forgets the appraisal stamps, for a new login. + public void Reset() => _idStamps.Clear(); + + public IReadOnlyList CaptureOwned() => + _context.Automation.Items.CaptureOwnedItems(); + + public static bool TryFind(IReadOnlyList items, uint objectId, out PluginInventoryItem item) + { + foreach (PluginInventoryItem candidate in items) + { + if (candidate.ObjectId != objectId) + continue; + item = candidate; + return true; + } + item = default; + return false; + } + + /// + /// True once the item has been appraised this session. Any property data + /// makes the host report appraisal data, so the appraisal time is what + /// tells a real appraisal from a lone property update. + /// + public bool IsIdentified(uint objectId) => + _context.Automation.Objects.TryGet(objectId, out PluginWorldObject world) + && world.HasAppraisalData + && world.LastIdTime != 0; + + /// The item record for one owned item, as it stands now. + public MyWorldObject Build(in PluginInventoryItem item) + { + bool identified = false; + int lastIdTime = 0; + if (_context.Automation.Objects.TryGet(item.ObjectId, out PluginWorldObject world) + && world.HasAppraisalData + && world.LastIdTime != 0) + { + identified = true; + lastIdTime = Stamp(item.ObjectId, world.LastIdTime); + } + + PluginItemProperties? properties = + _context.Automation.Items.TryCaptureProperties(item.ObjectId, out PluginItemProperties captured) + ? captured + : null; + + return DecalItemBuilder.Create(new ItemSource( + item, properties, identified, lastIdTime, _headers.Read(item))); + } + + /// + /// The original's test for an item worth appraising: armor, clothing, + /// weapons, casters, jewelry, Aetheria and summoning essences. + /// + public static bool NeedsIdent(PluginObjectClass objectClass, string? name) => + objectClass == PluginObjectClass.Armor + || objectClass == PluginObjectClass.Clothing + || objectClass == PluginObjectClass.MeleeWeapon + || objectClass == PluginObjectClass.MissileWeapon + || objectClass == PluginObjectClass.WandStaffOrb + || objectClass == PluginObjectClass.Jewelry + || (objectClass == PluginObjectClass.Gem && !string.IsNullOrEmpty(name) && name.Contains("Aetheria", StringComparison.Ordinal)) + || (objectClass == PluginObjectClass.Misc && !string.IsNullOrEmpty(name) && name.Contains("Essence", StringComparison.Ordinal)); + + /// + /// The original's inventory test: the item sits in (or is wielded by) the + /// character, or sits in a pack the character holds. + /// + public bool IsPlayerInventory(uint containerObjectId, uint wielderObjectId) + { + uint characterId = CharacterId; + uint container = containerObjectId != 0u ? containerObjectId : wielderObjectId; + if (characterId == 0u || container == 0u) + return false; + if (container == characterId) + return true; + return _context.Automation.Objects.TryGet(container, out PluginWorldObject pack) + && pack.ObjectClass == PluginObjectClass.Container + && pack.ContainerObjectId == characterId; + } + + /// The number of Prismatic Tapers in the packs. + public int CountPrismaticTapers() + { + int count = 0; + foreach (PluginInventoryItem item in CaptureOwned()) + { + if (string.Equals(item.Name, PrismaticTaper, StringComparison.OrdinalIgnoreCase) + && IsPlayerInventory(item.ContainerObjectId, 0u)) + { + count += item.StackSize; + } + } + return count; + } + + private int Stamp(uint objectId, int hostTime) + { + if (_idStamps.TryGetValue(objectId, out var stamp) && stamp.HostTime == hostTime) + return stamp.UnixSeconds; + int unix = unchecked((int)new DateTimeOffset(_context.Clock.UtcNow).ToUnixTimeSeconds()); + _idStamps[objectId] = (hostTime, unix); + return unix; + } +} diff --git a/tests/OpenAC.MosswartMassacre.Tests/Inventory/DecalItemBuilderTests.cs b/tests/OpenAC.MosswartMassacre.Tests/Inventory/DecalItemBuilderTests.cs new file mode 100644 index 0000000..ba9a2cc --- /dev/null +++ b/tests/OpenAC.MosswartMassacre.Tests/Inventory/DecalItemBuilderTests.cs @@ -0,0 +1,114 @@ +using AcDream.Plugin.Abstractions; +using OpenAC.MosswartMassacre.Backend; +using OpenAC.MosswartMassacre.Inventory; +using static OpenAC.MosswartMassacre.Tests.Inventory.InventoryWorld; + +namespace OpenAC.MosswartMassacre.Tests.Inventory; + +public sealed class DecalItemBuilderTests +{ + [Fact] + public void With_the_header_flags_known_a_field_sent_as_zero_is_present_and_an_unsent_one_absent() + { + // The original's dumps hold "218103822": 0 for items whose header + // carried a zero equipable-slots field (1153 of 3058 items). + PluginInventoryItem tapers = Tapers(); + uint flags = HeaderFlags.Value | HeaderFlags.Usable | HeaderFlags.ValidLocations + | HeaderFlags.StackSize | HeaderFlags.MaxStackSize | HeaderFlags.PluralName; + + MyWorldObject record = DecalItemBuilder.Create(new ItemSource( + tapers, null, false, 0, new ItemHeaderWords { WeenieHeaderFlags = flags })); + + Assert.Equal(0, record.IntValues[DecalKeys.EquipableSlots]); + Assert.Equal(unchecked((int)flags), record.IntValues[DecalKeys.CreateFlags1]); + Assert.False(record.IntValues.ContainsKey(DecalKeys.RawBurden)); + } + + [Fact] + public void Without_the_header_flags_a_zero_field_is_left_out_and_the_value_key_reads_minus_one() + { + PluginInventoryItem bare = FakeItemsBare(0x80000555u, "Bare Thing"); + + MyWorldObject record = DecalItemBuilder.Create(new ItemSource(bare, null, false, 0, default)); + + Assert.Equal(-1, record.IntValues[DecalKeys.RawValue]); + Assert.Equal(-1, record.Value); + Assert.False(record.IntValues.ContainsKey(DecalKeys.EquipableSlots)); + Assert.False(record.IntValues.ContainsKey(DecalKeys.CreateFlags1)); + Assert.False(record.IntValues.ContainsKey(DecalKeys.PhysicsDataFlags)); + Assert.False(record.IntValues.ContainsKey(DecalKeys.Setup)); + } + + [Fact] + public void The_physics_and_second_header_words_are_written_when_known() + { + PluginInventoryItem tachi = Tachi(appraised: false); + var words = new ItemHeaderWords + { + WeenieHeaderFlags2 = 1u, + PhysicsDescriptionFlags = 0x21881u, + PhysicsWord = 17u, + SetupId = 0x02000155u, + Scale = 0d, + ParentObjectId = Me, + ParentLocation = 1, + HookType = 2, + }; + + MyWorldObject record = DecalItemBuilder.Create(new ItemSource(tachi, null, false, 0, words)); + + Assert.Equal(1, record.IntValues[DecalKeys.CreateFlags2]); + Assert.Equal(0x21881, record.IntValues[DecalKeys.PhysicsDataFlags]); + Assert.Equal(17, record.IntValues[DecalKeys.PhysicsWord]); + Assert.Equal(0x02000155, record.IntValues[DecalKeys.Setup]); + Assert.Equal(0d, record.DoubleValues[DecalKeys.Scale]); + Assert.Equal(unchecked((int)Me), record.IntValues[DecalKeys.Wielder]); + Assert.Equal(1, record.IntValues[DecalKeys.WieldingSlot]); + Assert.Equal(2, record.IntValues[DecalKeys.HookType]); + } + + [Fact] + public void Combine_keeps_an_earlier_appraisal_and_refreshes_it_from_the_live_item() + { + // MyWorldObjectCreator.cs:44-61. + MyWorldObject older = DecalItemBuilder.Create(new ItemSource( + Tachi(appraised: true), TachiProps(), true, 1700000000, default)); + PluginInventoryItem moved = Tachi(appraised: false) with { WielderObjectId = 0u, ContainerObjectId = 0x80000900u }; + MyWorldObject newer = DecalItemBuilder.Create(new ItemSource(moved, null, false, 0, default)); + + MyWorldObject combined = DecalItemBuilder.Combine(older, newer); + + Assert.Same(older, combined); + Assert.True(combined.HasIdData); + Assert.Equal(1700000000, combined.LastIdTime); + Assert.Equal([2598, 2603], combined.Spells); + Assert.Equal(50, combined.MaxDamage); + Assert.Equal(unchecked((int)0x80000900u), combined.ContainerId); + Assert.Equal(unchecked((int)0x80000900u), combined.IntValues[DecalKeys.Container]); + Assert.Equal(combined.IntValues.Keys.Order(), combined.IntValues.Keys); + } + + [Fact] + public void Combine_takes_the_live_item_when_it_is_appraised_or_the_old_one_was_not() + { + MyWorldObject older = DecalItemBuilder.Create(new ItemSource(Tapers(), null, false, 0, default)); + MyWorldObject newer = DecalItemBuilder.Create(new ItemSource(Tapers(60), null, false, 0, default)); + + Assert.Same(newer, DecalItemBuilder.Combine(older, newer)); + } + + [Fact] + public void A_saved_record_reads_back_and_writes_the_same_frame() + { + MyWorldObject record = DecalItemBuilder.Create(new ItemSource( + Breastplate(), BreastplateProps(), true, 1790325015, default)); + string json = Wire.Serialize(record); + + MyWorldObject? back = Newtonsoft.Json.JsonConvert.DeserializeObject(json); + + Assert.Equal(json, Wire.Serialize(back)); + } + + private static PluginInventoryItem FakeItemsBare(uint id, string name) => + Fakes.FakeItems.Item(id, name) with { ObjectClass = PluginObjectClass.Misc, MaximumStackSize = 1 }; +} diff --git a/tests/OpenAC.MosswartMassacre.Tests/Inventory/EquipmentCantripStateTests.cs b/tests/OpenAC.MosswartMassacre.Tests/Inventory/EquipmentCantripStateTests.cs new file mode 100644 index 0000000..1ed51a2 --- /dev/null +++ b/tests/OpenAC.MosswartMassacre.Tests/Inventory/EquipmentCantripStateTests.cs @@ -0,0 +1,95 @@ +using AcDream.Plugin.Abstractions; +using Newtonsoft.Json.Linq; +using OpenAC.MosswartMassacre.Inventory; +using static OpenAC.MosswartMassacre.Tests.Inventory.InventoryWorld; + +namespace OpenAC.MosswartMassacre.Tests.Inventory; + +public sealed class EquipmentCantripStateTests +{ + private static PluginHarness Arrange(params PluginActiveEnchantment[] enchantments) => + new("Mossy", arrange: host => + { + Own(host, Tachi(appraised: true), TachiProps(), hostIdTime: 5000); + host.Automation.Spells.All = + [ + Spell(2598, family: 100, difficulty: 150), + Spell(2603, family: 101, difficulty: 150), + Spell(5183, family: 200, difficulty: 400), + Spell(6089, family: 100, difficulty: 400), + Spell(6094, family: 101, difficulty: 400), + ]; + host.Automation.Character.ActiveEnchantments = enchantments; + }); + + private static JToken OnlyItem(PluginHarness harness) => + JObject.Parse(harness.FramesOfTypeRaw("equipment_cantrip_state").Last())["items"]!.Single(); + + [Fact] + public void A_timed_buff_does_not_keep_an_item_spell_running_only_an_item_cast_one_does() + { + using var harness = Arrange( + new PluginActiveEnchantment(6089, 100, 8, double.PositiveInfinity), + new PluginActiveEnchantment(6094, 101, 8, 1800d)); + harness.Login(); + harness.Feature().SendSnapshot(); + + JToken item = OnlyItem(harness); + Assert.Equal("not_active", (string)item["state"]!); + Assert.Equal(1500, (int)item["current_mana"]!); + Assert.Equal(JTokenType.Null, item["mana_time_remaining_seconds"]!.Type); + } + + [Fact] + public void An_item_without_mana_is_not_active_and_one_without_spells_is_left_out() + { + using var harness = Arrange(); + PluginItemProperties empty = TachiProps(); + var ints = new Dictionary(empty.Ints) { [107] = 0 }; + Own(harness.Host, Tachi(appraised: true), empty with { Ints = ints }, hostIdTime: 5000); + harness.Login(); + harness.Feature().SendSnapshot(); + Assert.Equal("not_active", (string)OnlyItem(harness)["state"]!); + + Own(harness.Host, Tachi(appraised: false) with { AppraisedSpellIds = [] }, TachiProps(), hostIdTime: 5000); + harness.Feature().SendSnapshot(); + Assert.Empty(JObject.Parse(harness.FramesOfTypeRaw("equipment_cantrip_state").Last())["items"]!); + } + + [Fact] + public void Changes_within_a_quarter_second_send_one_frame() + { + using var harness = Arrange(); + harness.Login(); + harness.Tick(0.3); + harness.Frames.Clear(); + + Raise(harness.Host, 0x80000202u, PluginObjectChangeKind.Updated); + harness.Tick(0.1); + Raise(harness.Host, 0x80000202u, PluginObjectChangeKind.Updated); + harness.Tick(0.2); + Assert.Empty(harness.FramesOfTypeRaw("equipment_cantrip_state")); + harness.Tick(0.1); + + Assert.Single(harness.FramesOfTypeRaw("equipment_cantrip_state")); + } + + [Fact] + public void A_change_in_the_characters_enchantments_sends_a_frame() + { + using var harness = Arrange(); + harness.Login(); + harness.Tick(0.3); + harness.Frames.Clear(); + + harness.Tick(1.0); + Assert.Empty(harness.FramesOfTypeRaw("equipment_cantrip_state")); + + harness.Host.Automation.Character.ActiveEnchantments = + [new PluginActiveEnchantment(6089, 100, 8, double.PositiveInfinity)]; + harness.Tick(1.0); + harness.Tick(0.3); + + Assert.Single(harness.FramesOfTypeRaw("equipment_cantrip_state")); + } +} diff --git a/tests/OpenAC.MosswartMassacre.Tests/Inventory/IdentifyQueueTests.cs b/tests/OpenAC.MosswartMassacre.Tests/Inventory/IdentifyQueueTests.cs new file mode 100644 index 0000000..c09a4eb --- /dev/null +++ b/tests/OpenAC.MosswartMassacre.Tests/Inventory/IdentifyQueueTests.cs @@ -0,0 +1,88 @@ +using AcDream.Plugin.Abstractions; +using OpenAC.MosswartMassacre.Inventory; +using OpenAC.MosswartMassacre.Tests.Fakes; + +namespace OpenAC.MosswartMassacre.Tests.Inventory; + +public sealed class IdentifyQueueTests +{ + private static (IdentifyQueue Queue, FakeAutomation Automation, List<(uint, bool)> Finished) Create() + { + var automation = new FakeAutomation { IsAvailable = true }; + var queue = new IdentifyQueue(() => automation); + var finished = new List<(uint, bool)>(); + queue.Finished += (id, ok) => finished.Add((id, ok)); + return (queue, automation, finished); + } + + [Fact] + public void Requests_go_out_one_at_a_time_and_the_next_waits_for_the_answer() + { + var (queue, automation, finished) = Create(); + queue.Enqueue(1); + queue.Enqueue(2); + queue.Enqueue(1); + + queue.Pump(0.015); + queue.Pump(0.015); + Assert.Equal([1u], automation.Objects.IdentifyRequests); + + queue.OnIdentReceived(1); + queue.Pump(0.015); + Assert.Equal([1u, 2u], automation.Objects.IdentifyRequests); + Assert.Equal([(1u, true)], finished); + Assert.False(queue.IsIdle); + + queue.OnIdentReceived(2); + Assert.True(queue.IsIdle); + } + + [Fact] + public void A_busy_slot_is_asked_again_on_a_later_tick() + { + var (queue, automation, _) = Create(); + automation.Objects.IdentifyBusyForCalls[7] = 2; + queue.Enqueue(7); + + queue.Pump(0.015); + queue.Pump(0.015); + queue.Pump(0.015); + + Assert.Equal([7u, 7u, 7u], automation.Objects.IdentifyRequests); + Assert.Equal(1, queue.Count); + } + + [Fact] + public void The_host_answer_or_abandon_finishes_a_request() + { + var (queue, automation, finished) = Create(); + queue.Enqueue(3); + queue.Enqueue(4); + queue.Pump(0.015); + + automation.Loot.Appraisal = new PluginAppraisalState(5, 0u, 3u); + queue.Pump(0.015); + automation.Loot.Appraisal = new PluginAppraisalState(6, 0u, 3u, LastAbandonedObjectId: 4u); + queue.Pump(0.015); + + Assert.Equal([(3u, true), (4u, false)], finished); + } + + [Fact] + public void An_unanswered_request_is_given_up_after_the_timeout_and_a_refusal_at_once() + { + var (queue, automation, finished) = Create(); + automation.Objects.IdentifyResultOverrides[8] = PluginItemCommandStatus.InvalidItem; + queue.Enqueue(8); + queue.Enqueue(9); + + queue.Pump(0.015); + Assert.Equal([(8u, false)], finished); + + for (int i = 0; i < 700; i++) + queue.Pump(0.015); + + Assert.Equal([(8u, false), (9u, false)], finished); + Assert.True(queue.IsIdle); + } +} diff --git a/tests/OpenAC.MosswartMassacre.Tests/Inventory/InventoryFeatureTests.cs b/tests/OpenAC.MosswartMassacre.Tests/Inventory/InventoryFeatureTests.cs new file mode 100644 index 0000000..e283ed5 --- /dev/null +++ b/tests/OpenAC.MosswartMassacre.Tests/Inventory/InventoryFeatureTests.cs @@ -0,0 +1,255 @@ +using AcDream.Plugin.Abstractions; +using Newtonsoft.Json.Linq; +using OpenAC.MosswartMassacre.Inventory; +using static OpenAC.MosswartMassacre.Tests.Inventory.InventoryWorld; + +namespace OpenAC.MosswartMassacre.Tests.Inventory; + +public sealed class InventoryFeatureTests +{ + private const string Prefix = "[Mosswart Massacre] "; + + [Fact] + public void First_login_appraises_what_needs_it_and_sends_only_when_the_answers_are_in() + { + using var harness = new PluginHarness("Mossy", arrange: host => + { + Own(host, Tachi(appraised: false)); + Own(host, Tapers()); + }); + LoginAndSettle(harness); + harness.Tick(0.1); + + Assert.Contains(Prefix + "Requesting id information for all armor/weapon inventory. This will take a few minutes...", harness.ChatLines); + Assert.Equal([0x80000202u], harness.Host.Automation.Objects.IdentifyRequests); + harness.Tick(0.1); + Assert.Empty(harness.FramesOfTypeRaw("full_inventory")); + + Own(harness.Host, Tachi(appraised: true), TachiProps(), hostIdTime: 5000); + Raise(harness.Host, 0x80000202u, PluginObjectChangeKind.IdentReceived); + harness.Tick(0.1); + + JObject frame = JObject.Parse(harness.FramesOfTypeRaw("full_inventory").Single()); + Assert.Equal(2, (int)frame["item_count"]!); + Assert.True((bool)frame["items"]![0]!["HasIdData"]!); + Assert.Contains(Prefix + "Requesting id information for all armor/weapon inventory completed. Log file written.", harness.ChatLines); + Assert.Contains(Prefix + "Inventory sent to MosswartOverlord", harness.ChatLines); + Assert.NotNull(harness.Host.Storage.ReadText(InventoryFeature.CacheKey)); + } + + [Fact] + public void A_later_login_sends_at_once_and_keeps_the_saved_appraisal_of_an_unappraised_item() + { + using var harness = new PluginHarness("Mossy", arrange: host => + Own(host, Tachi(appraised: true), TachiProps(), hostIdTime: 5000)); + LoginAndSettle(harness); + harness.Tick(0.1); + harness.Tick(0.1); + Assert.Single(harness.FramesOfTypeRaw("full_inventory")); + + // Relog: the session's appraisal is gone, the saved copy is not. + harness.Plugin.OnLogoff(); + Own(harness.Host, Tachi(appraised: false)); + harness.Frames.Clear(); + harness.Host.Automation.Objects.IdentifyRequests.Clear(); + LoginAndSettle(harness); + harness.Tick(0.1); + + JObject frame = JObject.Parse(harness.FramesOfTypeRaw("full_inventory").Single()); + JToken item = frame["items"]![0]!; + Assert.True((bool)item["HasIdData"]!); + Assert.Equal(50, (int)item["MaxDamage"]!); + Assert.Equal("Light Weapons", (string)item["EquipSkill"]!); + // Saved data present, so the original did not ask again from the dump + // (MossyInventory.cs:253); the login stream already asked once. + Assert.Equal([0x80000202u], harness.Host.Automation.Objects.IdentifyRequests); + } + + [Fact] + public void Sendinventory_sends_at_once_when_everything_is_appraised() + { + using var harness = new PluginHarness("Mossy", arrange: host => Own(host, Tapers())); + LoginAndSettle(harness); + harness.Tick(0.1); + harness.Tick(0.1); + harness.Frames.Clear(); + + harness.Context.Commands.Dispatch("sendinventory"); + + Assert.Single(harness.FramesOfTypeRaw("full_inventory")); + Assert.Contains(Prefix + "[INV] All 1 items already identified. Sending...", harness.ChatLines); + Assert.Contains(Prefix + "[INV] Inventory upload completed", harness.ChatLines); + } + + [Fact] + public void Sendinventory_waits_for_appraisals_and_refuses_when_streaming_is_off() + { + using var harness = new PluginHarness("Mossy", arrange: host => Own(host, Tapers())); + LoginAndSettle(harness); + harness.Tick(0.1); + harness.Tick(0.1); + harness.Frames.Clear(); + Own(harness.Host, Ring()); + + harness.Context.Commands.Dispatch("sendinventory"); + Assert.Contains(Prefix + "[INV] Scanning 2 items, requesting ID for 1. Waiting for identification...", harness.ChatLines); + harness.Tick(0.1); + Assert.Empty(harness.FramesOfTypeRaw("full_inventory")); + + Own(harness.Host, Ring(), Props(ints: new() { [107] = 10, [108] = 20 }), hostIdTime: 42); + Raise(harness.Host, 0x80000404u, PluginObjectChangeKind.IdentReceived); + harness.Tick(0.1); + Assert.Single(harness.FramesOfTypeRaw("full_inventory")); + Assert.Contains(Prefix + "[INV] All items identified. Inventory upload completed.", harness.ChatLines); + + harness.Context.Settings.Current.WebSocketEnabled = false; + harness.Context.Commands.Dispatch("sendinventory"); + Assert.Contains(Prefix + "[INV] WebSocket streaming is disabled", harness.ChatLines); + } + + [Fact] + public void The_login_stream_is_tracked_without_adds_and_items_after_it_are_added() + { + using var harness = new PluginHarness("Mossy"); + harness.Login(); + InventoryFeature feature = harness.Feature(); + feature.IsBackendReady = () => true; + + Own(harness.Host, Tapers()); + Raise(harness.Host, 0x80000101u, PluginObjectChangeKind.Created); + Assert.False(feature.IsSettled); + for (int i = 0; i < 40 && !feature.IsSettled; i++) + harness.Tick(0.1); + + Assert.Empty(harness.FramesOfTypeRaw("inventory_delta")); + Assert.Equal(1, feature.TrackedCount); + + Own(harness.Host, Breastplate(), BreastplateProps(), hostIdTime: 9); + Raise(harness.Host, 0x80000303u, PluginObjectChangeKind.Created); + Assert.Equal("add", (string)JObject.Parse(harness.FramesOfTypeRaw("inventory_delta").Single())["action"]!); + } + + [Fact] + public void Changes_are_held_until_the_jittered_flush_between_two_and_five_minutes() + { + using var harness = new PluginHarness("Mossy", arrange: host => Own(host, Tapers())); + InventoryFeature feature = LoginAndSettle(harness); + harness.Frames.Clear(); + + Own(harness.Host, Tapers(60)); + Raise(harness.Host, 0x80000101u, PluginObjectChangeKind.Updated); + Raise(harness.Host, 0x80000101u, PluginObjectChangeKind.Updated); + Assert.Equal(1, feature.DirtyCount); + + for (int i = 0; i < 1190; i++) + harness.Tick(0.1); + Assert.Empty(harness.FramesOfTypeRaw("inventory_delta")); + + for (int i = 0; i < 1820 && harness.FramesOfTypeRaw("inventory_delta").Count == 0; i++) + harness.Tick(0.1); + + JObject update = JObject.Parse(harness.FramesOfTypeRaw("inventory_delta").Single()); + Assert.Equal("update", (string)update["action"]!); + Assert.Equal(60, (int)update["item"]!["IntValues"]!["218103814"]!); + } + + [Fact] + public void The_flush_interval_is_two_minutes_plus_up_to_three() + { + using var harness = new PluginHarness("Mossy"); + InventoryFeature feature = harness.Feature(); + + int[] samples = Enumerable.Range(0, 2000).Select(_ => feature.NextFlushIntervalMs()).ToArray(); + + Assert.All(samples, ms => Assert.InRange(ms, 120000, 300000)); + Assert.True(samples.Max() - samples.Min() > 150000); + } + + [Fact] + public void An_item_moved_out_of_the_inventory_is_removed_and_one_in_a_side_pack_is_added() + { + const uint pack = 0x80000900u; + using var harness = new PluginHarness("Mossy", arrange: host => + { + Own(host, Tapers()); + host.Automation.Objects.Replace(new PluginWorldObject(pack, 1u, "Pack", PluginObjectClass.Container, 0x200u, Me, 0u)); + }); + LoginAndSettle(harness); + harness.Frames.Clear(); + + Own(harness.Host, Ring() with { ContainerObjectId = pack, WielderObjectId = 0u, EquippedLocation = 0u }); + Raise(harness.Host, 0x80000404u, PluginObjectChangeKind.Moved); + Own(harness.Host, Tapers() with { ContainerObjectId = 0x7000AAAAu }); + Raise(harness.Host, 0x80000101u, PluginObjectChangeKind.Moved); + + string[] actions = harness.FramesOfTypeRaw("inventory_delta") + .Select(f => (string)JObject.Parse(f)["action"]!).ToArray(); + Assert.Equal(["add", "remove"], actions); + } + + [Fact] + public void A_relog_starts_tracking_afresh() + { + using var harness = new PluginHarness("Mossy", arrange: host => Own(host, Tapers())); + InventoryFeature feature = LoginAndSettle(harness); + Own(harness.Host, Tapers(60)); + Raise(harness.Host, 0x80000101u, PluginObjectChangeKind.Updated); + Assert.Equal(1, feature.DirtyCount); + + harness.Plugin.OnLogoff(); + Assert.Equal(0, feature.DirtyCount); + Assert.Equal(0, feature.TrackedCount); + + harness.Host.Automation.Character.Name = "Horan"; + harness.Frames.Clear(); + LoginAndSettle(harness); + Raise(harness.Host, 0x80000101u, PluginObjectChangeKind.Updated); + + Assert.Empty(harness.FramesOfTypeRaw("inventory_delta")); + Assert.Equal(1, feature.DirtyCount); + } + + [Fact] + public void Logoff_sends_the_inventory_while_the_socket_is_still_up() + { + using var harness = new PluginHarness("Mossy", arrange: host => Own(host, Tapers())); + LoginAndSettle(harness); + harness.Tick(0.1); + harness.Tick(0.1); + harness.Frames.Clear(); + + harness.Host.Events.RaiseLogoff(); + + Assert.Single(harness.FramesOfTypeRaw("full_inventory")); + } + + [Fact] + public void The_taper_count_follows_the_stacks_in_the_packs() + { + using var harness = new PluginHarness("Mossy", arrange: host => Own(host, Tapers())); + InventoryFeature feature = LoginAndSettle(harness); + Assert.Equal(57, feature.PrismaticTaperCount); + + Own(harness.Host, Tapers(12)); + Raise(harness.Host, 0x80000101u, PluginObjectChangeKind.Updated); + Assert.Equal(12, feature.PrismaticTaperCount); + + Forget(harness.Host, 0x80000101u); + harness.Host.Events.RaiseObjectChanged(new PluginObjectChange(0x80000101u, PluginObjectChangeKind.Released)); + Assert.Equal(0, feature.PrismaticTaperCount); + } + + [Fact] + public void Inventory_log_off_sends_no_login_inventory() + { + using var harness = new PluginHarness("Mossy", arrange: host => Own(host, Tapers())); + harness.Login(); + harness.Context.Settings.Current.InventoryLog = false; + InventoryFeature feature = harness.Feature(); + feature.IsBackendReady = () => true; + for (int i = 0; i < 40; i++) + harness.Tick(0.1); + + Assert.Empty(harness.FramesOfTypeRaw("full_inventory")); + } +} diff --git a/tests/OpenAC.MosswartMassacre.Tests/Inventory/InventoryGoldenTests.cs b/tests/OpenAC.MosswartMassacre.Tests/Inventory/InventoryGoldenTests.cs new file mode 100644 index 0000000..263fffc --- /dev/null +++ b/tests/OpenAC.MosswartMassacre.Tests/Inventory/InventoryGoldenTests.cs @@ -0,0 +1,155 @@ +using AcDream.Plugin.Abstractions; +using OpenAC.MosswartMassacre.Inventory; +using static OpenAC.MosswartMassacre.Tests.Inventory.InventoryWorld; + +namespace OpenAC.MosswartMassacre.Tests.Inventory; + +/// +/// Byte-for-byte frames, derived by hand from the original plugin's code. +/// Shape sources (original plugin, head 48cf13b): +/// +/// Item record: Shared/MyWorldObject.cs:16-29 (fields, in order) and +/// :118-315 (getters, in order; doubles stay doubles so -1 is written -1.0). +/// Record contents: Shared/MyWorldObjectCreator.cs:20-39 — the framework's +/// key tables, spells, active spells, id, appraisal time, class, icon, container. +/// Which framework keys exist and what they hold: the original's own item +/// dumps (3058 items), see . +/// Envelopes: MosswartMassacre/WebSocket.cs:339-380 (full_inventory, +/// inventory_delta add/update and remove) and :411-423 (equipment_cantrip_state); +/// the per-item mana entry EquipmentCantripStateTracker.cs:222-229. +/// +/// +public sealed class InventoryGoldenTests +{ + // The harness clock, 2026-09-25 08:30:15.123 UTC, in Unix seconds: the + // original stamped an appraisal with the wall clock when it arrived. + private const string IdStamp = "1790325015"; + + // Getters from ElementalDmgBonus onward for an item with none of their keys + // except Value/Burden (MyWorldObject.cs:163-315). + private const string TaperRecord = + """{"HasIdData":false,"Id":-2147483391,"LastIdTime":0,"ObjectClass":12,"Icon":11658,"ContainerId":1342177281,"BoolValues":{},"DoubleValues":{},"IntValues":{"5":57,"19":570,"218103808":20631,"218103809":11658,"218103810":1342177281,"218103814":57,"218103815":100,"218103834":4096,"218103835":18,"218103843":1},"StringValues":{"1":"Prismatic Taper","184549376":"Prismatic Tapers"},"ActiveSpells":[],"Spells":[],"Material":null,"Name":"Prismatic Taper","EquipSkill":null,"Mastery":null,"ItemSet":null,"ArmorLevel":-1,"Imbue":null,"Tinks":-1,"MaxDamage":-1,"ElementalDmgBonus":-1,"Variance":-1.0,"DamageBonus":-1.0,"ElementalDamageVersusMonsters":-1.0,"AttackBonus":-1.0,"MeleeDefenseBonus":-1.0,"MagicDBonus":-1.0,"MissileDBonus":-1.0,"ManaCBonus":-1.0,"WieldLevel":-1,"SkillLevel":-1,"LoreRequirement":-1,"SalvageWorkmanship":-1.0,"Workmanship":-1,"Value":570,"Burden":57,"DamRating":-1,"DamResistRating":-1,"CritRating":-1,"CritResistRating":-1,"CritDamRating":-1,"CritDamResistRating":-1,"HealBoostRating":-1,"VitalityRating":-1,"TotalRating":-1,"CalcedStartingArmorLevel":-1,"CalcedBuffedTinkedDoT":-1.0,"CalcedBuffedMissileDamage":-1.0,"BuffedElementalDamageVersusMonsters":-1.0,"BuffedAttackBonus":-1.0,"BuffedMeleeDefenseBonus":-1.0,"BuffedManaCBonus":-1.0}"""; + + // Armor profile -> 167772160..166 (slash, pierce, bludgeon, acid, lightning, fire, + // cold), floats widened; spell book split on the 0x80000000 bit; ItemSet 5 from + // Dictionaries.AttributeSetInfo; CalcedStartingArmorLevel (MyWorldObject.cs:232-265): + // 600 - 200 (no material) - (240 active Incantation of Impenetrability - 20 Minor + // Impenetrability bonus) = 180. + private const string BreastplateRecord = + """{"HasIdData":true,"Id":-2147482877,"LastIdTime":""" + IdStamp + ""","ObjectClass":2,"Icon":4660,"ContainerId":1342177281,"BoolValues":{"69":true},"DoubleValues":{"167772160":1.2000000476837158,"167772161":1.0,"167772162":0.800000011920929,"167772163":0.20000000298023224,"167772164":1.5,"167772165":0.4000000059604645,"167772166":0.6000000238418579},"IntValues":{"5":800,"19":5000,"28":600,"265":5,"218103808":6044,"218103809":4660,"218103810":1342177281,"218103821":1024,"218103822":512,"218103824":1,"218103834":2,"218103835":18,"218103838":2,"218103843":1,"218103848":2},"StringValues":{"1":"Celdon Breastplate"},"ActiveSpells":[4407,1483],"Spells":[1486,2604],"Material":null,"Name":"Celdon Breastplate","EquipSkill":null,"Mastery":null,"ItemSet":"Noble Relic Set","ArmorLevel":600,"Imbue":null,"Tinks":-1,"MaxDamage":-1,"ElementalDmgBonus":-1,"Variance":-1.0,"DamageBonus":-1.0,"ElementalDamageVersusMonsters":-1.0,"AttackBonus":-1.0,"MeleeDefenseBonus":-1.0,"MagicDBonus":-1.0,"MissileDBonus":-1.0,"ManaCBonus":-1.0,"WieldLevel":-1,"SkillLevel":-1,"LoreRequirement":-1,"SalvageWorkmanship":-1.0,"Workmanship":-1,"Value":5000,"Burden":800,"DamRating":-1,"DamResistRating":-1,"CritRating":-1,"CritResistRating":-1,"CritDamRating":-1,"CritDamResistRating":-1,"HealBoostRating":-1,"VitalityRating":-1,"TotalRating":-1,"CalcedStartingArmorLevel":180,"CalcedBuffedTinkedDoT":-1.0,"CalcedBuffedMissileDamage":-1.0,"BuffedElementalDamageVersusMonsters":-1.0,"BuffedAttackBonus":-1.0,"BuffedMeleeDefenseBonus":-1.0,"BuffedManaCBonus":-1.0}"""; + + // Wielded: the container key and ContainerId are the wielder. The second header + // (public flags 0x04000000) brings the underlay key, here 0. + private const string TachiUnappraisedRecord = + """{"HasIdData":false,"Id":-2147483134,"LastIdTime":0,"ObjectClass":1,"Icon":13162,"ContainerId":1342177281,"BoolValues":{},"DoubleValues":{"167772169":8.0},"IntValues":{"5":270,"10":1048576,"19":12000,"131":64,"218103808":30310,"218103809":13162,"218103810":1342177281,"218103822":1048576,"218103823":1,"218103824":1,"218103826":16,"218103834":1,"218103835":67108882,"218103843":6291460,"218103849":6958,"218103850":0},"StringValues":{"1":"Fire Tachi"},"ActiveSpells":[],"Spells":[],"Material":"Steel","Name":"Fire Tachi","EquipSkill":null,"Mastery":null,"ItemSet":null,"ArmorLevel":-1,"Imbue":null,"Tinks":-1,"MaxDamage":-1,"ElementalDmgBonus":-1,"Variance":-1.0,"DamageBonus":-1.0,"ElementalDamageVersusMonsters":-1.0,"AttackBonus":-1.0,"MeleeDefenseBonus":-1.0,"MagicDBonus":-1.0,"MissileDBonus":-1.0,"ManaCBonus":-1.0,"WieldLevel":-1,"SkillLevel":-1,"LoreRequirement":-1,"SalvageWorkmanship":8.0,"Workmanship":-1,"Value":12000,"Burden":270,"DamRating":-1,"DamResistRating":-1,"CritRating":-1,"CritResistRating":-1,"CritDamRating":-1,"CritDamResistRating":-1,"HealBoostRating":-1,"VitalityRating":-1,"TotalRating":-1,"CalcedStartingArmorLevel":-1,"CalcedBuffedTinkedDoT":-1.0,"CalcedBuffedMissileDamage":-1.0,"BuffedElementalDamageVersusMonsters":-1.0,"BuffedAttackBonus":-1.0,"BuffedMeleeDefenseBonus":-1.0,"BuffedManaCBonus":-1.0}"""; + + // Weapon profile -> 218103839..842 and 167772171..174. Getters: EquipSkill 45 -> + // "Light Weapons", Mastery 2 -> "Sword", Imbue 1 -> "CS", SkillLevel (158 != 7) 370, + // TotalRating 2. CalcedBuffedTinkedDoT (MyWorldObject.cs:270-302): max damage + // 50 - 24 (active Incantation of Blood Drinker) + 2 (Minor Blood Thirst) = 28, seven + // tinks left, giving 55.431200000000004. BuffedAttackBonus: 1.2000000476837158 + .03 + // (Minor Heart Thirst) = 1.2300000476837158. + private const string TachiAppraisedRecord = + """{"HasIdData":true,"Id":-2147483134,"LastIdTime":""" + IdStamp + ""","ObjectClass":1,"Icon":13162,"ContainerId":1342177281,"BoolValues":{},"DoubleValues":{"5":-0.05,"29":1.15,"167772169":8.0,"167772171":0.5,"167772172":1.2000000476837158,"167772173":1.0,"167772174":1.0},"IntValues":{"5":270,"10":1048576,"19":12000,"105":8,"107":1500,"108":2000,"109":300,"131":64,"158":2,"159":45,"160":370,"171":3,"179":1,"353":2,"370":2,"218103808":30310,"218103809":13162,"218103810":1342177281,"218103822":1048576,"218103823":1,"218103824":1,"218103826":16,"218103834":1,"218103835":67108882,"218103838":2,"218103839":30,"218103840":45,"218103841":16,"218103842":50,"218103843":6291460,"218103848":1,"218103849":6958,"218103850":0},"StringValues":{"1":"Fire Tachi","16":"Fire Tachi of Blood Drinker"},"ActiveSpells":[5183],"Spells":[2598,2603],"Material":"Steel","Name":"Fire Tachi","EquipSkill":"Light Weapons","Mastery":"Sword","ItemSet":null,"ArmorLevel":-1,"Imbue":"CS","Tinks":3,"MaxDamage":50,"ElementalDmgBonus":-1,"Variance":0.5,"DamageBonus":1.0,"ElementalDamageVersusMonsters":-1.0,"AttackBonus":1.2000000476837158,"MeleeDefenseBonus":1.15,"MagicDBonus":-1.0,"MissileDBonus":-1.0,"ManaCBonus":-1.0,"WieldLevel":-1,"SkillLevel":370,"LoreRequirement":300,"SalvageWorkmanship":8.0,"Workmanship":8,"Value":12000,"Burden":270,"DamRating":2,"DamResistRating":-1,"CritRating":-1,"CritResistRating":-1,"CritDamRating":-1,"CritDamResistRating":-1,"HealBoostRating":-1,"VitalityRating":-1,"TotalRating":2,"CalcedStartingArmorLevel":-1,"CalcedBuffedTinkedDoT":55.431200000000004,"CalcedBuffedMissileDamage":-1.0,"BuffedElementalDamageVersusMonsters":-1.0,"BuffedAttackBonus":1.2300000476837158,"BuffedMeleeDefenseBonus":1.15,"BuffedManaCBonus":-1.0}"""; + + [Fact] + public void Full_inventory_frame_after_the_first_login() + { + using var harness = new PluginHarness("Mossy", arrange: host => + { + Own(host, Tapers()); + Own(host, Breastplate(), BreastplateProps(), hostIdTime: 777); + }); + + LoginAndSettle(harness); + harness.Tick(0.1); // the login upload: no saved copy, so appraise and wait + harness.Tick(0.1); // nothing needed appraising: send + + // WebSocket.cs:341-348: type, timestamp, character_name, item_count, items. + string expected = + """{"type":"full_inventory","timestamp":"2026-09-25T08:30:15.1230000Z","character_name":"Mossy","item_count":2,"items":[""" + + TaperRecord + "," + BreastplateRecord + "]}"; + Assert.Equal([expected], harness.FramesOfTypeRaw("full_inventory")); + } + + [Fact] + public void Inventory_delta_add_then_update_after_appraisal() + { + using var harness = new PluginHarness("Mossy"); + InventoryFeature feature = LoginAndSettle(harness); + harness.Tick(0.1); + harness.Tick(0.1); + harness.Frames.Clear(); + + Own(harness.Host, Tachi(appraised: false)); + Raise(harness.Host, 0x80000202u, PluginObjectChangeKind.Created); + + // WebSocket.cs:356-363: type, timestamp, character_name, action, item. + Assert.Equal( + ["""{"type":"inventory_delta","timestamp":"2026-09-25T08:30:15.1230000Z","character_name":"Mossy","action":"add","item":""" + TachiUnappraisedRecord + "}"], + harness.FramesOfTypeRaw("inventory_delta")); + + harness.Tick(0.015); + Assert.Contains(0x80000202u, harness.Host.Automation.Objects.IdentifyRequests); + + Own(harness.Host, Tachi(appraised: true), TachiProps(), hostIdTime: 5000); + Raise(harness.Host, 0x80000202u, PluginObjectChangeKind.IdentReceived); + harness.Frames.Clear(); + feature.FlushDirtyItems(); + + Assert.Equal( + ["""{"type":"inventory_delta","timestamp":"2026-09-25T08:30:15.1230000Z","character_name":"Mossy","action":"update","item":""" + TachiAppraisedRecord + "}"], + harness.FramesOfTypeRaw("inventory_delta")); + } + + [Fact] + public void Inventory_delta_remove_carries_only_the_id() + { + using var harness = new PluginHarness("Mossy", arrange: host => Own(host, Tapers())); + LoginAndSettle(harness); + harness.Frames.Clear(); + + Forget(harness.Host, 0x80000101u); + harness.Host.Events.RaiseObjectChanged(new PluginObjectChange(0x80000101u, PluginObjectChangeKind.Released)); + + // WebSocket.cs:370-377: the id is the framework's signed 32-bit id. + Assert.Equal( + ["""{"type":"inventory_delta","timestamp":"2026-09-25T08:30:15.1230000Z","character_name":"Mossy","action":"remove","item_id":-2147483391}"""], + harness.FramesOfTypeRaw("inventory_delta")); + } + + [Fact] + public void Equipment_cantrip_state_for_an_active_weapon_and_an_unappraised_ring() + { + using var harness = new PluginHarness("Mossy", arrange: host => + { + Own(host, Tachi(appraised: true), TachiProps(), hostIdTime: 5000); + Own(host, Ring()); + host.Automation.Spells.All = + [ + Spell(2598, family: 100, difficulty: 150), + Spell(2603, family: 101, difficulty: 150), + Spell(5183, family: 200, difficulty: 400), + Spell(6089, family: 100, difficulty: 400), + Spell(6094, family: 101, difficulty: 400), + ]; + host.Automation.Character.ActiveEnchantments = + [ + new PluginActiveEnchantment(6089, 100, 8, double.PositiveInfinity), + new PluginActiveEnchantment(6094, 101, 8, double.PositiveInfinity), + ]; + }); + harness.Login(); + harness.Frames.Clear(); + + // Mana was last reported at login; 65 s later three points have burned at + // one per ceil(-0.2 / -0.05) * 5 = 20 s (EquipmentCantripStateTracker.cs:296-345). + harness.Clock.Advance(TimeSpan.FromSeconds(65)); + harness.Feature().SendSnapshot(); + + // WebSocket.cs:413-419 envelope; items from EquipmentCantripStateTracker.cs:222-229. + Assert.Equal( + ["""{"type":"equipment_cantrip_state","timestamp":"2026-09-25T08:31:20.1230000Z","character_name":"Mossy","items":[{"item_id":-2147483134,"state":"active","current_mana":1497,"max_mana":2000,"mana_time_remaining_seconds":29940.0},{"item_id":-2147482620,"state":"unknown","current_mana":null,"max_mana":null,"mana_time_remaining_seconds":null}]}"""], + harness.FramesOfTypeRaw("equipment_cantrip_state")); + } +} diff --git a/tests/OpenAC.MosswartMassacre.Tests/Inventory/InventoryWorld.cs b/tests/OpenAC.MosswartMassacre.Tests/Inventory/InventoryWorld.cs new file mode 100644 index 0000000..8cf4265 --- /dev/null +++ b/tests/OpenAC.MosswartMassacre.Tests/Inventory/InventoryWorld.cs @@ -0,0 +1,205 @@ +using AcDream.Plugin.Abstractions; +using Newtonsoft.Json.Linq; +using OpenAC.MosswartMassacre.Inventory; +using OpenAC.MosswartMassacre.Tests.Fakes; + +namespace OpenAC.MosswartMassacre.Tests.Inventory; + +/// +/// Puts items into the fake host the way the real one holds them: the item +/// in the owned list, its world object in the object table, its property +/// tables when it has any. +/// +internal static class InventoryWorld +{ + /// The harness character's id. + public const uint Me = 0x50000001; + + public static PluginItemProperties Props( + Dictionary? ints = null, + Dictionary? floats = null, + Dictionary? bools = null, + Dictionary? strings = null, + PluginWeaponProfile? weapon = null, + PluginArmorProfile? armor = null) + => new( + ints ?? [], + new Dictionary(), + bools ?? [], + floats ?? [], + strings ?? [], + new Dictionary(), + new Dictionary()) + { + WeaponProfile = weapon, + ArmorProfile = armor, + }; + + /// + /// Adds or replaces an owned item. non-zero + /// with properties means appraised. + /// + public static void Own( + FakeHost host, + PluginInventoryItem item, + PluginItemProperties? properties = null, + int hostIdTime = 0) + { + List owned = host.Automation.Items.Owned; + int index = owned.FindIndex(existing => existing.ObjectId == item.ObjectId); + if (index >= 0) + owned[index] = item; + else + owned.Add(item); + + if (properties is { } props) + host.Automation.Items.Properties[item.ObjectId] = props; + else + host.Automation.Items.Properties.Remove(item.ObjectId); + + host.Automation.Objects.Replace(World(item, properties is not null, hostIdTime)); + } + + /// Takes an item out of the owned list and the object table. + public static void Forget(FakeHost host, uint objectId) + { + host.Automation.Items.Owned.RemoveAll(item => item.ObjectId == objectId); + host.Automation.Items.Properties.Remove(objectId); + host.Automation.Objects.Objects.RemoveAll(o => o.ObjectId == objectId); + } + + public static PluginWorldObject World(PluginInventoryItem item, bool hasProperties, int hostIdTime) + => new( + item.ObjectId, + item.WeenieClassId, + item.Name, + item.ObjectClass, + item.ItemType, + item.ContainerObjectId, + item.WielderObjectId) + { + IsOwned = true, + HasAppraisalData = hasProperties, + LastIdTime = hostIdTime, + SpellIds = item.AppraisedSpellIds, + IconId = item.IconId, + }; + + /// A change as the host raises it, with the object's current snapshot. + public static PluginObjectChange Change(FakeHost host, uint objectId, PluginObjectChangeKind kind) + => new(objectId, kind) + { + Current = host.Automation.Objects.TryGet(objectId, out PluginWorldObject current) + ? current + : null, + }; + + public static void Raise(FakeHost host, uint objectId, PluginObjectChangeKind kind) + => host.Events.RaiseObjectChanged(Change(host, objectId, kind)); + + /// Logs in and runs the ticks it takes the inventory to settle, without moving the clock. + public static InventoryFeature LoginAndSettle(PluginHarness harness) + { + harness.Login(); + InventoryFeature feature = harness.Feature(); + feature.IsBackendReady = () => true; + for (int i = 0; i < 40 && !feature.IsSettled; i++) + harness.Tick(0.1); + Assert.True(feature.IsSettled); + return feature; + } + + /// A stack of 57 Prismatic Tapers in the main pack, never appraised. + public static PluginInventoryItem Tapers(int stack = 57) => + new( + 0x80000101u, 20631u, "Prismatic Taper", 0x1000u, Me, 0u, 0u, 0u, + 1u, 0u, 18u, stack, 0, 0, 0u, 0, 0, 0u, false, 0d, 0, 0, 0, 0d, 0, 0, 0) + { + ObjectClass = PluginObjectClass.SpellComponent, + IconId = 0x06002D8Au, + MaximumStackSize = 100, + ContainerSlot = 0, + Burden = stack, + Value = stack * 10, + PluralName = "Prismatic Tapers", + }; + + /// A breastplate in the main pack, appraised, with two carried and two running spells. + public static PluginInventoryItem Breastplate() => + new( + 0x80000303u, 6044u, "Celdon Breastplate", 0x2u, Me, 0u, 512u, 0u, + 1u, 0u, 18u, 1, 0, 0, 0u, 0, 0, 0u, false, 0d, 0, 0, 0, 0d, 0, 0, 0) + { + ObjectClass = PluginObjectClass.Armor, + IconId = 0x06001234u, + ContainerSlot = 3, + Burden = 800, + Value = 5000, + CoverageMask = 1024u, + Effects = 1u, + AppraisedSpellIds = [1486u, 2604u, 0x80000000u | 4407u, 0x80000000u | 1483u], + }; + + public static PluginItemProperties BreastplateProps() => + Props( + ints: new() { [28] = 600, [265] = 5 }, + bools: new() { [69] = true }, + armor: new PluginArmorProfile(600, 1.2f, 1.0f, 0.8f, 0.6f, 0.4f, 0.2f, 0f, 1.5f)); + + /// A wielded sword, before (no spells) or after appraisal. + public static PluginInventoryItem Tachi(bool appraised) => + new( + 0x80000202u, 30310u, "Fire Tachi", 0x1u, 0u, Me, 1048576u, 1048576u, + 6291460u, 16u, 0x04000012u, 1, 0, 0, 0u, 0, 0, 0u, false, 0d, 0, 0, 0, 0d, 0, 0, 0) + { + ObjectClass = PluginObjectClass.MeleeWeapon, + IconId = 0x0600336Au, + IconOverlayId = 0x06001B2Eu, + IconUnderlayId = 0u, + ContainerSlot = -1, + CombatUse = 1, + Burden = 270, + Value = 12000, + MaterialType = 64u, + Workmanship = 8f, + Effects = 1u, + AppraisedSpellIds = appraised ? [2598u, 2603u, 0x80000000u | 5183u] : [], + }; + + public static PluginItemProperties TachiProps() => + Props( + ints: new() + { + [105] = 8, [107] = 1500, [108] = 2000, [109] = 300, [158] = 2, [159] = 45, + [160] = 370, [171] = 3, [179] = 1, [353] = 2, [370] = 2, + }, + floats: new() { [5] = -0.05, [29] = 1.15 }, + strings: new() { [16] = "Fire Tachi of Blood Drinker" }, + weapon: new PluginWeaponProfile(16, 30, 45u, 50, 0.5, 1.0, 1.5, 1.0, 1.2000000476837158, 0)); + + /// A ring the character wears, never appraised. + public static PluginInventoryItem Ring() => + new( + 0x80000404u, 2575u, "Gold Ring", 0x8u, 0u, Me, 0xC0000u, 0x40000u, + 1u, 0u, 18u, 1, 0, 0, 0u, 0, 0, 0u, false, 0d, 0, 0, 0, 0d, 0, 0, 0) + { + ObjectClass = PluginObjectClass.Jewelry, + IconId = 0x06000F6Eu, + ContainerSlot = -1, + Burden = 30, + Value = 400, + }; + + public static PluginSpellInfo Spell(uint id, uint family, int difficulty, bool debuff = false) + => new(id, $"Spell {id}", family, 1, difficulty, 10, -1f, 3u, string.Empty, false, true) + { + IsDebuff = debuff, + }; +} + +internal static class InventoryHarnessExtensions +{ + /// The raw frames of one type, in the order they were sent. + public static List FramesOfTypeRaw(this PluginHarness harness, string type) => + harness.Frames.Where(frame => (string?)JObject.Parse(frame)["type"] == type).ToList(); +} From c1e8a2cf186a755afe9344d1c6b6356e9c3e2e5e Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 25 Sep 2026 12:41:00 +0200 Subject: [PATCH 3/3] Inventory: stamp an appraisal when it arrives The original's appraisal time was the wall clock at the moment the appraisal came in. The reader stamped it when it first built the record, which for a coalesced update can be minutes later; the appraisal event now stamps it. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Inventory/InventoryFeature.cs | 3 +++ .../Inventory/InventoryReader.cs | 10 ++++++++++ .../Inventory/InventoryFeatureTests.cs | 18 ++++++++++++++++++ 3 files changed, 31 insertions(+) diff --git a/src/OpenAC.MosswartMassacre/Inventory/InventoryFeature.cs b/src/OpenAC.MosswartMassacre/Inventory/InventoryFeature.cs index f30b222..d5dd032 100644 --- a/src/OpenAC.MosswartMassacre/Inventory/InventoryFeature.cs +++ b/src/OpenAC.MosswartMassacre/Inventory/InventoryFeature.cs @@ -424,7 +424,10 @@ internal sealed class InventoryFeature : IMmFeature try { if (change.Kind == PluginObjectChangeKind.IdentReceived) + { + _reader.NoteAppraisal(change.ObjectId); _identify.OnIdentReceived(change.ObjectId); + } PluginWorldObject? current = change.Current; if (change.Kind == PluginObjectChangeKind.Released diff --git a/src/OpenAC.MosswartMassacre/Inventory/InventoryReader.cs b/src/OpenAC.MosswartMassacre/Inventory/InventoryReader.cs index 032c573..c5712bb 100644 --- a/src/OpenAC.MosswartMassacre/Inventory/InventoryReader.cs +++ b/src/OpenAC.MosswartMassacre/Inventory/InventoryReader.cs @@ -57,6 +57,16 @@ internal sealed class InventoryReader && 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) { diff --git a/tests/OpenAC.MosswartMassacre.Tests/Inventory/InventoryFeatureTests.cs b/tests/OpenAC.MosswartMassacre.Tests/Inventory/InventoryFeatureTests.cs index e283ed5..a659950 100644 --- a/tests/OpenAC.MosswartMassacre.Tests/Inventory/InventoryFeatureTests.cs +++ b/tests/OpenAC.MosswartMassacre.Tests/Inventory/InventoryFeatureTests.cs @@ -252,4 +252,22 @@ public sealed class InventoryFeatureTests 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"]!); + } }