diff --git a/src/OpenAC.MosswartMassacre/FeatureCatalog.cs b/src/OpenAC.MosswartMassacre/FeatureCatalog.cs index b86fd7f..a4580a1 100644 --- a/src/OpenAC.MosswartMassacre/FeatureCatalog.cs +++ b/src/OpenAC.MosswartMassacre/FeatureCatalog.cs @@ -1,6 +1,7 @@ using OpenAC.MosswartMassacre.Backend; using OpenAC.MosswartMassacre.FlagTracker; using OpenAC.MosswartMassacre.Infrastructure; +using OpenAC.MosswartMassacre.Inventory; using OpenAC.MosswartMassacre.Metas; using OpenAC.MosswartMassacre.Quests; using OpenAC.MosswartMassacre.Streams; @@ -64,5 +65,14 @@ internal static class FeatureCatalog mainWindow.FlagTrackerOpener = () => trackerPanel.IsVisible = true; if (metas.Panel is { } metasPanel) mainWindow.MetasOpener = () => metasPanel.IsVisible = true; + + // Inventory: full_inventory, inventory_delta, equipment_cantrip_state, + // and the taper count telemetry reads (InventoryFeature.PrismaticTaperCount). + var inventoryReader = new InventoryReader(context, HostItemHeaderSource.Instance); + var identifyQueue = new IdentifyQueue(() => context.Automation); + var inventory = new InventoryFeature(context, inventoryReader, identifyQueue); + features.Add(inventory); + context.PrismaticTaperCount = () => inventory.PrismaticTaperCount; + features.Add(new EquipmentCantripStateFeature(context, inventoryReader)); } } diff --git a/src/OpenAC.MosswartMassacre/Inventory/DecalItemBuilder.cs b/src/OpenAC.MosswartMassacre/Inventory/DecalItemBuilder.cs new file mode 100644 index 0000000..a9b7124 --- /dev/null +++ b/src/OpenAC.MosswartMassacre/Inventory/DecalItemBuilder.cs @@ -0,0 +1,262 @@ +using AcDream.Plugin.Abstractions; + +namespace OpenAC.MosswartMassacre.Inventory; + +/// +/// Everything the builder reads for one item, gathered on the tick thread. +/// +/// The item's header fields as the host reports them. +/// The item's raw property tables and appraisal profiles, when the host has them. +/// True once the item has been appraised this session. +/// When it was appraised, in Unix seconds; zero when it has not been. +/// The header words the contract does not expose yet. +internal readonly record struct ItemSource( + PluginInventoryItem Item, + PluginItemProperties? Properties, + bool HasIdData, + int LastIdTime, + ItemHeaderWords Words); + +/// +/// Builds the item record the backend expects from what the host reports: +/// the raw property tables as they are, plus the framework keys the original +/// filled from the object's header and appraisal profiles. +/// +/// +/// +/// Which keys appear, and when, follows the original's own item dumps: a +/// header field's key is present exactly when the server sent that field, +/// the value key is always present (-1 when the server sent none), the +/// underlay key accompanies the second header word, the spell counts come +/// with a non-empty appraised spell book, and the weapon and armor keys with +/// their appraisal profiles. The slot key was never in the tables and is not +/// written. +/// +/// +/// The tables are written in ascending key order. The original wrote them in +/// its framework's hash-table order, which depends on the insertion history +/// and cannot be reproduced; the backend reads them by key. +/// +/// +internal static class DecalItemBuilder +{ + /// The item's own active enchantments carry this bit in the appraised spell book. + public const uint ActiveSpellBit = 0x80000000u; + + private const uint IconBase = 0x06000000u; + + /// The original's MyWorldObjectCreator.Create. + public static MyWorldObject Create(in ItemSource source) + { + PluginInventoryItem item = source.Item; + ItemHeaderWords words = source.Words; + var ints = new Dictionary(); + var doubles = new Dictionary(); + var bools = new Dictionary(); + var strings = new Dictionary(); + + if (source.Properties is { } properties) + { + foreach (KeyValuePair kvp in properties.Ints ?? EmptyInts) + ints[unchecked((int)kvp.Key)] = kvp.Value; + foreach (KeyValuePair kvp in properties.Floats ?? EmptyDoubles) + doubles[unchecked((int)kvp.Key)] = kvp.Value; + foreach (KeyValuePair kvp in properties.Bools ?? EmptyBools) + bools[unchecked((int)kvp.Key)] = kvp.Value; + foreach (KeyValuePair kvp in properties.Strings ?? EmptyStrings) + strings[unchecked((int)kvp.Key)] = kvp.Value; + } + + bool Sent(uint flag, bool whenUnknown) => + words.WeenieHeaderFlags is uint flags ? (flags & flag) != 0u : whenUnknown; + + // Always in the header. + strings[DecalKeys.RawName] = item.Name; + ints[DecalKeys.WeenieClassId] = unchecked((int)item.WeenieClassId); + ints[DecalKeys.Icon] = ShortIcon(item.IconId); + ints[DecalKeys.Category] = unchecked((int)item.ItemType); + ints[DecalKeys.Behavior] = unchecked((int)item.PublicFlags); + int container = ContainerOf(item); + ints[DecalKeys.Container] = container; + + if (Sent(HeaderFlags.PluralName, !string.IsNullOrEmpty(item.PluralName))) + strings[DecalKeys.SecondaryName] = item.PluralName; + if (Sent(HeaderFlags.ItemsCapacity | HeaderFlags.ContainersCapacity, item.ItemsCapacity != 0 || item.ContainersCapacity != 0)) + { + ints[DecalKeys.ItemSlots] = item.ItemsCapacity; + ints[DecalKeys.PackSlots] = item.ContainersCapacity; + } + if (Sent(HeaderFlags.Value, item.Value != 0)) + ints[DecalKeys.RawValue] = item.Value; + else if (!ints.ContainsKey(DecalKeys.RawValue)) + ints[DecalKeys.RawValue] = -1; + if (Sent(HeaderFlags.Usable, item.Useability != 0u)) + ints[DecalKeys.Usable] = unchecked((int)item.Useability); + if (Sent(HeaderFlags.UseRadius, item.UseRadius != 0f)) + doubles[DecalKeys.ApproachDistance] = item.UseRadius; + if (Sent(HeaderFlags.UiEffects, item.Effects != 0u)) + ints[DecalKeys.IconOutline] = unchecked((int)item.Effects); + if (Sent(HeaderFlags.AmmoType, item.AmmoType != 0u)) + ints[DecalKeys.MissileType] = unchecked((int)item.AmmoType); + if (Sent(HeaderFlags.CombatUse, item.CombatUse != 0)) + ints[DecalKeys.EquipType] = item.CombatUse; + if (Sent(HeaderFlags.Structure | HeaderFlags.MaxStructure, item.MaximumStructure != 0 || item.Structure != 0)) + { + ints[DecalKeys.RawMaxStructure] = item.MaximumStructure; + ints[DecalKeys.RawStructure] = item.Structure; + } + if (Sent(HeaderFlags.StackSize | HeaderFlags.MaxStackSize, item.MaximumStackSize > 1)) + { + ints[DecalKeys.StackCount] = item.StackSize; + ints[DecalKeys.StackMax] = item.MaximumStackSize; + } + if (Sent(HeaderFlags.ValidLocations, item.ValidLocations != 0u)) + ints[DecalKeys.EquipableSlots] = unchecked((int)item.ValidLocations); + if (Sent(HeaderFlags.CurrentlyWieldedLocation, item.EquippedLocation != 0u)) + ints[DecalKeys.RawEquippedSlots] = unchecked((int)item.EquippedLocation); + if (Sent(HeaderFlags.Priority, item.CoverageMask != 0u)) + ints[DecalKeys.Coverage] = unchecked((int)item.CoverageMask); + if (Sent(HeaderFlags.TargetType, item.TargetType != 0u)) + ints[DecalKeys.UsageMask] = unchecked((int)item.TargetType); + if (Sent(HeaderFlags.Burden, item.Burden != 0)) + ints[DecalKeys.RawBurden] = item.Burden; + if (Sent(HeaderFlags.Spell, item.SpellId != 0u)) + ints[DecalKeys.SpellDid] = unchecked((int)item.SpellId); + if (Sent(HeaderFlags.Workmanship, item.Workmanship != 0f)) + doubles[DecalKeys.SalvageWorkmanship] = item.Workmanship; + if (words.HookType is int hookType && Sent(HeaderFlags.HookType, true)) + ints[DecalKeys.HookType] = hookType; + if (Sent(HeaderFlags.IconOverlay, item.IconOverlayId != 0u)) + ints[DecalKeys.IconOverlay] = ShortIcon(item.IconOverlayId); + if (Sent(HeaderFlags.MaterialType, item.MaterialType != 0u)) + ints[DecalKeys.RawMaterial] = unchecked((int)item.MaterialType); + + if ((item.PublicFlags & HeaderFlags.IncludesSecondHeader) != 0u) + { + ints[DecalKeys.IconUnderlay] = ShortIcon(item.IconUnderlayId); + if (words.WeenieHeaderFlags2 is uint flags2) + ints[DecalKeys.CreateFlags2] = unchecked((int)flags2); + } + + if (words.WeenieHeaderFlags is uint headerFlags) + ints[DecalKeys.CreateFlags1] = unchecked((int)headerFlags); + if (words.PhysicsDescriptionFlags is uint physicsFlags) + ints[DecalKeys.PhysicsDataFlags] = unchecked((int)physicsFlags); + if (words.PhysicsWord is uint physicsWord) + ints[DecalKeys.PhysicsWord] = unchecked((int)physicsWord); + if (words.SetupId is uint setup) + ints[DecalKeys.Setup] = unchecked((int)setup); + if (words.Scale is double scale) + doubles[DecalKeys.Scale] = scale; + if (words.ParentObjectId is uint parent) + ints[DecalKeys.Wielder] = unchecked((int)parent); + if (words.ParentLocation is int parentLocation) + ints[DecalKeys.WieldingSlot] = parentLocation; + + // From the appraisal. + var spells = new List(); + var activeSpells = new List(); + IReadOnlyList spellBook = item.AppraisedSpellIds ?? Array.Empty(); + foreach (uint entry in spellBook) + { + if ((entry & ActiveSpellBit) != 0u) + activeSpells.Add(unchecked((int)(entry & ~ActiveSpellBit))); + else + spells.Add(unchecked((int)entry)); + } + if (spellBook.Count > 0) + { + ints[DecalKeys.SpellCount] = spells.Count; + ints[DecalKeys.ActiveSpellCount] = activeSpells.Count; + } + + if (source.Properties?.WeaponProfile is { } weapon) + { + ints[DecalKeys.WeapSpeed] = weapon.WeaponTime; + ints[DecalKeys.EquipSkill] = unchecked((int)weapon.WeaponSkill); + ints[DecalKeys.DamageType] = weapon.DamageType; + ints[DecalKeys.MaxDamage] = weapon.Damage; + doubles[DecalKeys.Variance] = weapon.DamageVariance; + doubles[DecalKeys.AttackBonus] = weapon.WeaponOffense; + doubles[DecalKeys.Range] = weapon.MaxVelocity; + doubles[DecalKeys.DamageBonus] = weapon.DamageMod; + } + + if (source.Properties?.ArmorProfile is { } armor) + { + doubles[DecalKeys.SlashProt] = armor.SlashMod; + doubles[DecalKeys.PierceProt] = armor.PierceMod; + doubles[DecalKeys.BludgeonProt] = armor.BludgeonMod; + doubles[DecalKeys.AcidProt] = armor.AcidMod; + doubles[DecalKeys.LightningProt] = armor.ElectricMod; + doubles[DecalKeys.FireProt] = armor.FireMod; + doubles[DecalKeys.ColdProt] = armor.ColdMod; + } + + return new MyWorldObject + { + HasIdData = source.HasIdData, + Id = unchecked((int)item.ObjectId), + LastIdTime = source.LastIdTime, + ObjectClass = (int)item.ObjectClass, + Icon = ShortIcon(item.IconId), + ContainerId = container, + BoolValues = Sorted(bools), + DoubleValues = Sorted(doubles), + IntValues = Sorted(ints), + StringValues = Sorted(strings), + ActiveSpells = activeSpells, + Spells = spells, + }; + } + + /// + /// The original's MyWorldObjectCreator.Combine: an item remembered + /// with appraisal data from an earlier session keeps that data while the + /// live item has none, refreshed by whatever the live item does carry. + /// + public static MyWorldObject Combine(MyWorldObject older, MyWorldObject newer) + { + // Repairs an icon missing from an older saved record. + if (older.Icon == 0 && newer.Icon != 0) + older.Icon = newer.Icon; + + if (!older.HasIdData || newer.HasIdData) + return newer; + + older.AddTo(newer.BoolValues, newer.DoubleValues, newer.IntValues, newer.StringValues); + older.ContainerId = newer.ContainerId; + older.BoolValues = Sorted(older.BoolValues); + older.DoubleValues = Sorted(older.DoubleValues); + older.IntValues = Sorted(older.IntValues); + older.StringValues = Sorted(older.StringValues); + return older; + } + + /// + /// An icon id as the framework reported it: without the icon table's + /// 0x06000000 prefix. + /// + public static int ShortIcon(uint iconId) => + iconId >= IconBase ? unchecked((int)(iconId - IconBase)) : unchecked((int)iconId); + + /// + /// The framework's container of an item: the pack holding it, or the + /// creature wielding it when it is wielded. + /// + public static int ContainerOf(in PluginInventoryItem item) => + unchecked((int)(item.ContainerObjectId != 0u ? item.ContainerObjectId : item.WielderObjectId)); + + private static Dictionary Sorted(Dictionary table) + { + var sorted = new Dictionary(table.Count); + foreach (int key in table.Keys.Order()) + sorted[key] = table[key]; + return sorted; + } + + private static readonly IReadOnlyDictionary EmptyInts = new Dictionary(); + private static readonly IReadOnlyDictionary EmptyDoubles = new Dictionary(); + private static readonly IReadOnlyDictionary EmptyBools = new Dictionary(); + private static readonly IReadOnlyDictionary EmptyStrings = new Dictionary(); +} diff --git a/src/OpenAC.MosswartMassacre/Inventory/DecalKeys.cs b/src/OpenAC.MosswartMassacre/Inventory/DecalKeys.cs new file mode 100644 index 0000000..a4a5377 --- /dev/null +++ b/src/OpenAC.MosswartMassacre/Inventory/DecalKeys.cs @@ -0,0 +1,110 @@ +namespace OpenAC.MosswartMassacre.Inventory; + +/// +/// The pseudo keys the original's client framework mixed into an item's +/// property tables next to the raw game property ids. The backend reads +/// them by these numbers, so they are part of the wire. +/// +internal static class DecalKeys +{ + // Integer table. + public const int WeenieClassId = 218103808; + public const int Icon = 218103809; + public const int Container = 218103810; + public const int ItemSlots = 218103812; + public const int PackSlots = 218103813; + public const int StackCount = 218103814; + public const int StackMax = 218103815; + public const int SpellDid = 218103816; + public const int Wielder = 218103818; + public const int WieldingSlot = 218103819; + public const int Coverage = 218103821; + public const int EquipableSlots = 218103822; + public const int EquipType = 218103823; + public const int IconOutline = 218103824; + public const int MissileType = 218103825; + public const int UsageMask = 218103826; + public const int HookType = 218103828; + public const int Setup = 218103830; + public const int PhysicsWord = 218103831; + public const int CreateFlags1 = 218103832; + public const int CreateFlags2 = 218103833; + public const int Category = 218103834; + public const int Behavior = 218103835; + public const int SpellCount = 218103838; + public const int WeapSpeed = 218103839; + public const int EquipSkill = 218103840; + public const int DamageType = 218103841; + public const int MaxDamage = 218103842; + public const int Usable = 218103843; + public const int PhysicsDataFlags = 218103847; + public const int ActiveSpellCount = 218103848; + public const int IconOverlay = 218103849; + public const int IconUnderlay = 218103850; + + // Floating-point table. + public const int SlashProt = 167772160; + public const int PierceProt = 167772161; + public const int BludgeonProt = 167772162; + public const int AcidProt = 167772163; + public const int LightningProt = 167772164; + public const int FireProt = 167772165; + public const int ColdProt = 167772166; + public const int ApproachDistance = 167772168; + public const int SalvageWorkmanship = 167772169; + public const int Scale = 167772170; + public const int Variance = 167772171; + public const int AttackBonus = 167772172; + public const int Range = 167772173; + public const int DamageBonus = 167772174; + + // Text table. + public const int SecondaryName = 184549376; + + // Raw game property ids the framework filled from the object's header. + public const int RawBurden = 5; + public const int RawEquippedSlots = 10; + public const int RawValue = 19; + public const int RawMaxStructure = 91; + public const int RawStructure = 92; + public const int RawMaterial = 131; + public const int RawName = 1; + public const int RawManaRate = 5; + public const int RawCurrentMana = 107; + public const int RawMaximumMana = 108; +} + +/// +/// The bits of the object header's first flag word, which say which optional +/// header fields the server sent. The framework's key tables held a field +/// exactly when its bit was set. +/// +internal static class HeaderFlags +{ + public const uint PluralName = 0x00000001; + public const uint ItemsCapacity = 0x00000002; + public const uint ContainersCapacity = 0x00000004; + public const uint Value = 0x00000008; + public const uint Usable = 0x00000010; + public const uint UseRadius = 0x00000020; + public const uint UiEffects = 0x00000080; + public const uint AmmoType = 0x00000100; + public const uint CombatUse = 0x00000200; + public const uint Structure = 0x00000400; + public const uint MaxStructure = 0x00000800; + public const uint StackSize = 0x00001000; + public const uint MaxStackSize = 0x00002000; + public const uint ValidLocations = 0x00010000; + public const uint CurrentlyWieldedLocation = 0x00020000; + public const uint Priority = 0x00040000; + public const uint TargetType = 0x00080000; + public const uint Burden = 0x00200000; + public const uint Spell = 0x00400000; + public const uint Workmanship = 0x01000000; + public const uint HookType = 0x10000000; + public const uint IconOverlay = 0x40000000; + public const uint MaterialType = 0x80000000; + + /// The public-flags bit that says a second header word follows. + public const uint IncludesSecondHeader = 0x04000000; +} diff --git a/src/OpenAC.MosswartMassacre/Inventory/EquipmentCantripStateFeature.cs b/src/OpenAC.MosswartMassacre/Inventory/EquipmentCantripStateFeature.cs new file mode 100644 index 0000000..d123257 --- /dev/null +++ b/src/OpenAC.MosswartMassacre/Inventory/EquipmentCantripStateFeature.cs @@ -0,0 +1,425 @@ +using AcDream.Plugin.Abstractions; +using OpenAC.MosswartMassacre.Backend; + +namespace OpenAC.MosswartMassacre.Inventory; + +/// +/// The backend's mana panel: for every spell-bearing item the character +/// wears or wields, whether its spells are running, its mana and how long +/// that mana lasts, sent as equipment_cantrip_state a quarter second +/// after the last relevant change. +/// +/// +/// +/// An item is unknown until appraised, not_activatable (and left +/// out) without spells or mana, not_active when out of mana or when +/// one of its beneficial spells is neither running on the item nor matched +/// by an equal-or-stronger item spell on the character, and active +/// otherwise. The mana of an active item is projected down from the moment +/// its mana was last reported, at one point per burn interval. +/// +/// +/// The item is read through its item record, so every value is taken from +/// the same keys the original read. The host raises no event when the +/// character's enchantments change, so they are compared once a second. +/// +/// +internal sealed class EquipmentCantripStateFeature : IMmFeature +{ + public static readonly TimeSpan Debounce = TimeSpan.FromMilliseconds(250); + public static readonly TimeSpan EnchantmentPoll = TimeSpan.FromSeconds(1); + + private const int CloakSlot = 134217728; + private const int AmmoSlot = 8388608; + + private readonly MmContext _context; + private readonly InventoryReader _reader; + private readonly Action _onObjectChanged; + private readonly Action _onTick; + private readonly Dictionary _lastManaUpdateByItemId = []; + private readonly Dictionary _lastSeenMana = []; + private IDisposable? _debounce; + private bool _active; + private double _enchantmentPollSeconds; + private string _enchantmentSignature = string.Empty; + + public EquipmentCantripStateFeature(MmContext context, InventoryReader reader) + { + _context = context; + _reader = reader; + _onObjectChanged = OnObjectChanged; + _onTick = OnTick; + _context.Host.Events.ObjectChanged += _onObjectChanged; + _context.Host.Events.Tick += _onTick; + } + + public void OnLogin() + { + Cleanup(); + _active = true; + try + { + foreach (PluginInventoryItem item in _reader.CaptureOwned()) + RememberManaSnapshotTime(item.ObjectId); + _enchantmentSignature = EnchantmentSignature(); + RequestRefresh(); + } + catch (Exception ex) + { + _context.Host.Log.Warn($"[EquipCantrip] Initialize error: {ex.Message}"); + } + } + + public void OnLogoff() + { + _active = false; + Cleanup(); + } + + public void Dispose() + { + _context.Host.Events.ObjectChanged -= _onObjectChanged; + _context.Host.Events.Tick -= _onTick; + Cleanup(); + } + + private void Cleanup() + { + _debounce?.Dispose(); + _debounce = null; + _lastManaUpdateByItemId.Clear(); + _lastSeenMana.Clear(); + _enchantmentPollSeconds = 0d; + _enchantmentSignature = string.Empty; + } + + private void OnTick(double deltaSeconds) + { + if (!_active) + return; + _enchantmentPollSeconds += deltaSeconds; + if (_enchantmentPollSeconds < EnchantmentPoll.TotalSeconds) + return; + _enchantmentPollSeconds = 0d; + string signature = EnchantmentSignature(); + if (signature != _enchantmentSignature) + { + _enchantmentSignature = signature; + RequestRefresh(); + } + } + + private string EnchantmentSignature() => + string.Join(',', _context.Automation.Character.ActiveEnchantments.Select(e => e.SpellId).Order()); + + private void OnObjectChanged(PluginObjectChange change) + { + if (!_active) + return; + try + { + switch (change.Kind) + { + case PluginObjectChangeKind.Created: + if (change.Current is { } created && IsPotentiallyRelevant(created)) + { + RememberManaSnapshotTime(created.ObjectId); + RequestRefresh(); + } + break; + + case PluginObjectChangeKind.Released: + bool wasTracked = _lastManaUpdateByItemId.Remove(change.ObjectId); + _lastSeenMana.Remove(change.ObjectId); + if (wasTracked || (change.Current is { } released && IsPotentiallyRelevant(released))) + RequestRefresh(); + break; + + default: + if (change.Current is not { } changed) + break; + bool manaChanged = IsPotentiallyRelevant(changed) && ManaChanged(changed.ObjectId); + if (change.Kind == PluginObjectChangeKind.IdentReceived || manaChanged) + RememberManaSnapshotTime(changed.ObjectId); + if (IsPotentiallyRelevant(changed)) + RequestRefresh(); + break; + } + } + catch (Exception ex) + { + _context.Host.Log.Warn($"[EquipCantrip] Change error: {ex.Message}"); + } + } + + /// The host has no mana-change event; a changed current-mana value is one. + private bool ManaChanged(uint objectId) + { + int mana = CurrentMana(objectId); + return _lastSeenMana.TryGetValue(objectId, out int previous) && previous != mana; + } + + private int CurrentMana(uint objectId) => + _context.Automation.Items.TryCaptureProperties(objectId, out PluginItemProperties properties) + && properties.Ints is { } ints + && ints.TryGetValue(DecalKeys.RawCurrentMana, out int mana) + ? mana + : 0; + + private void RememberManaSnapshotTime(uint objectId) + { + _lastManaUpdateByItemId[objectId] = _context.Clock.UtcNow; + _lastSeenMana[objectId] = CurrentMana(objectId); + } + + private bool IsPotentiallyRelevant(PluginWorldObject wo) => + (wo.WielderObjectId != 0u && wo.WielderObjectId == _reader.CharacterId) + || _lastManaUpdateByItemId.ContainsKey(wo.ObjectId); + + private void RequestRefresh() + { + _debounce?.Dispose(); + _debounce = _context.Scheduler.Delay(Debounce, SendSnapshot); + } + + internal void SendSnapshot() + { + _debounce = null; + try + { + if (!_active || !_context.Settings.Current.WebSocketEnabled) + return; + + object[] items = BuildSnapshotItems().ToArray(); + _context.Send(new + { + type = "equipment_cantrip_state", + timestamp = Wire.Timestamp(_context.Clock), + character_name = _context.CharacterName, + items = items, + }); + } + catch (Exception ex) + { + _context.Host.Log.Warn($"[EquipCantrip] Send error: {ex.Message}"); + } + } + + private List BuildSnapshotItems() + { + var snapshotItems = new List(); + List activeCharacterSpells = GetActiveCharacterItemSpellData(); + foreach (PluginInventoryItem item in _reader.CaptureOwned()) + { + if (item.EquippedLocation == 0u) + continue; + MyWorldObject wo = _reader.Build(item); + if (!ShouldDisplayInManaPanel(wo, item)) + continue; + object? snapshot = BuildItemSnapshot(wo, activeCharacterSpells); + if (snapshot is not null) + snapshotItems.Add(snapshot); + } + return snapshotItems; + } + + /// + /// The character's item-cast enchantments, the ones with no end. The host + /// reports those as never running out. + /// + private List GetActiveCharacterItemSpellData() + { + var activeSpells = new List(); + foreach (PluginActiveEnchantment enchantment in _context.Automation.Character.ActiveEnchantments) + { + if (!double.IsPositiveInfinity(enchantment.SecondsRemaining) && enchantment.SecondsRemaining > 0d) + continue; + if (_context.Automation.Spells.TryGet(enchantment.SpellId, out PluginSpellInfo spell)) + activeSpells.Add(spell); + } + return activeSpells; + } + + private object? BuildItemSnapshot(MyWorldObject wo, List activeCharacterSpells) + { + ItemState itemState = RecalculateState(wo, activeCharacterSpells); + if (itemState == ItemState.NotActivatable) + return null; + + int maximumMana = Int(wo, DecalKeys.RawMaximumMana, 0); + int currentMana = itemState == ItemState.Unknown ? 0 : CalculateCurrentMana(wo, itemState); + double? manaTimeRemainingSeconds = null; + + if (itemState == ItemState.Active) + { + TimeSpan remaining = CalculateManaTimeRemaining(wo, currentMana); + manaTimeRemainingSeconds = remaining.TotalSeconds; + } + + return new + { + item_id = wo.Id, + state = ToWireState(itemState), + current_mana = itemState == ItemState.Unknown ? (int?)null : currentMana, + max_mana = itemState == ItemState.Unknown ? (int?)null : maximumMana, + mana_time_remaining_seconds = manaTimeRemainingSeconds, + }; + } + + private ItemState RecalculateState(MyWorldObject wo, List activeCharacterSpells) + { + if (!wo.HasIdData) + return ItemState.Unknown; + + if (Int(wo, DecalKeys.SpellCount, 0) == 0 || Int(wo, DecalKeys.RawMaximumMana, 0) == 0) + return ItemState.NotActivatable; + + if (Int(wo, DecalKeys.RawCurrentMana, 0) == 0) + return ItemState.NotActive; + + foreach (int spellOnItemId in wo.Spells) + { + if (wo.IntValues.TryGetValue(DecalKeys.SpellDid, out int associated) && associated == spellOnItemId) + continue; + + if (!_context.Automation.Spells.TryGet(unchecked((uint)spellOnItemId), out PluginSpellInfo spellOnItem)) + continue; + + if (spellOnItem.IsDebuff || spellOnItem.IsOffensive) + continue; + + bool thisSpellIsActive = false; + foreach (int activeSpellId in wo.ActiveSpells) + { + if (_context.Automation.Spells.TryGet(unchecked((uint)activeSpellId), out PluginSpellInfo activeSpellOnItem) + && SpellMatchesOrSurpasses(activeSpellOnItem, spellOnItem)) + { + thisSpellIsActive = true; + break; + } + } + + if (thisSpellIsActive) + continue; + + foreach (PluginSpellInfo activeSpellOnChar in activeCharacterSpells) + { + if (SpellMatchesOrSurpasses(activeSpellOnChar, spellOnItem)) + { + thisSpellIsActive = true; + break; + } + } + + if (!thisSpellIsActive) + return ItemState.NotActive; + } + + return ItemState.Active; + } + + private static bool SpellMatchesOrSurpasses(PluginSpellInfo activeSpell, PluginSpellInfo requiredSpell) => + activeSpell.Family == requiredSpell.Family && activeSpell.Difficulty >= requiredSpell.Difficulty; + + private int CalculateCurrentMana(MyWorldObject wo, ItemState itemState) + { + if (itemState == ItemState.Unknown || itemState == ItemState.NotActivatable) + return 0; + + if (itemState == ItemState.NotActive) + return Int(wo, DecalKeys.RawCurrentMana, 0); + + double secondsPerBurn = GetSecondsPerBurn(wo); + if (secondsPerBurn <= 0) + return Int(wo, DecalKeys.RawCurrentMana, 0); + + uint objectId = unchecked((uint)wo.Id); + DateTime now = _context.Clock.UtcNow; + if (!_lastManaUpdateByItemId.TryGetValue(objectId, out DateTime lastUpdate)) + { + lastUpdate = now; + _lastManaUpdateByItemId[objectId] = lastUpdate; + } + + TimeSpan timeSinceLastUpdate = now - lastUpdate; + int burnedMana = (int)(timeSinceLastUpdate.TotalSeconds / secondsPerBurn); + int currentMana = Int(wo, DecalKeys.RawCurrentMana, 0); + if (burnedMana > currentMana) + burnedMana = currentMana; + + return Math.Max(currentMana - burnedMana, 0); + } + + private static TimeSpan CalculateManaTimeRemaining(MyWorldObject wo, int calculatedCurrentMana) + { + double secondsPerBurn = GetSecondsPerBurn(wo); + if (secondsPerBurn <= 0) + return new TimeSpan(99, 99, 0); + + return TimeSpan.FromSeconds(calculatedCurrentMana * secondsPerBurn); + } + + private static double GetSecondsPerBurn(MyWorldObject wo) + { + if (!wo.HasIdData) + return 0; + + double manaRateOfChange = wo.DoubleValues.TryGetValue(DecalKeys.RawManaRate, out double rate) ? rate : 0; + if (manaRateOfChange == 0) + return 0; + + return ((int)Math.Ceiling(-0.2 / manaRateOfChange)) * 5; + } + + private bool ShouldDisplayInManaPanel(MyWorldObject wo, in PluginInventoryItem item) + { + if (!ItemIsEquippedByMe(wo, item)) + return false; + + if (!string.IsNullOrEmpty(wo.Name) && wo.Name.Contains("Aetheria", StringComparison.OrdinalIgnoreCase)) + return false; + + if (Int(wo, DecalKeys.EquipableSlots, 0) == CloakSlot) + return false; + + if (Int(wo, DecalKeys.RawEquippedSlots, 0) == AmmoSlot) + return false; + + return true; + } + + /// + /// Worn or wielded by this character. The framework's slot key read -1 + /// for anything not in a pack slot, and then the container decided. + /// + private bool ItemIsEquippedByMe(MyWorldObject wo, in PluginInventoryItem item) + { + int equippedSlots = Int(wo, DecalKeys.RawEquippedSlots, 0); + if (equippedSlots <= 0) + return false; + + if (item.ContainerSlot == -1) + return wo.ContainerId == unchecked((int)_reader.CharacterId); + + return true; + } + + private static int Int(MyWorldObject wo, int key, int defaultValue) => + wo.IntValues.TryGetValue(key, out int value) ? value : defaultValue; + + private static string ToWireState(ItemState itemState) => itemState switch + { + ItemState.Active => "active", + ItemState.NotActive => "not_active", + ItemState.NotActivatable => "not_activatable", + _ => "unknown", + }; + + private enum ItemState + { + Unknown, + NotActivatable, + Active, + NotActive, + } +} diff --git a/src/OpenAC.MosswartMassacre/Inventory/IdentifyQueue.cs b/src/OpenAC.MosswartMassacre/Inventory/IdentifyQueue.cs new file mode 100644 index 0000000..448ec64 --- /dev/null +++ b/src/OpenAC.MosswartMassacre/Inventory/IdentifyQueue.cs @@ -0,0 +1,123 @@ +using AcDream.Plugin.Abstractions; + +namespace OpenAC.MosswartMassacre.Inventory; + +/// +/// Appraises the player's items one at a time. The original fired an +/// appraisal request for every item at once and its framework queued them; +/// the host here has a single appraisal slot and answers +/// while it is taken, so the queue +/// asks again on a later tick instead. +/// +/// +/// A request is finished when the host reports it answered, gave up on it, +/// or when it has been outstanding for . The owner +/// pumps the queue from the tick; nothing here runs on another thread. +/// +internal sealed class IdentifyQueue +{ + /// How long one request may stay unanswered before it is given up. + public static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10); + + private readonly Func _automation; + private readonly LinkedList _pending = new(); + private readonly HashSet _queued = []; + private uint _inFlight; + private double _inFlightSeconds; + private long _appraisalRevisionAtRequest; + + public IdentifyQueue(Func automation) + { + _automation = automation; + } + + /// Raised with the item id and whether the appraisal arrived, once per finished request. + public event Action? Finished; + + /// True when nothing is waiting and nothing is outstanding. + public bool IsIdle => _inFlight == 0u && _pending.Count == 0; + + /// Requests queued or outstanding. + public int Count => _pending.Count + (_inFlight != 0u ? 1 : 0); + + /// Queues one item unless it is already queued or outstanding. + public void Enqueue(uint objectId) + { + if (objectId == 0u || objectId == _inFlight || !_queued.Add(objectId)) + return; + _pending.AddLast(objectId); + } + + /// Drops everything, for a logoff. + public void Clear() + { + _pending.Clear(); + _queued.Clear(); + _inFlight = 0u; + _inFlightSeconds = 0d; + } + + /// The host delivered appraisal data for an object. + public void OnIdentReceived(uint objectId) + { + if (objectId != 0u && objectId == _inFlight) + Finish(success: true); + } + + /// Advances the queue; call once per tick. + public void Pump(double deltaSeconds) + { + if (_inFlight != 0u) + { + _inFlightSeconds += Math.Max(0d, deltaSeconds); + PluginAppraisalState state = _automation().Loot.Appraisal; + if (state.Revision != _appraisalRevisionAtRequest) + { + if (state.CurrentObjectId == _inFlight) + { + Finish(success: true); + } + else if (state.LastAbandonedObjectId == _inFlight) + { + Finish(success: false); + } + } + if (_inFlight != 0u && _inFlightSeconds >= Timeout.TotalSeconds) + Finish(success: false); + if (_inFlight != 0u) + return; + } + + while (_pending.First is { } next) + { + uint objectId = next.Value; + IAutomationSurface automation = _automation(); + PluginItemCommandResult result = automation.Objects.Identify(objectId); + if (result.Status == PluginItemCommandStatus.Busy) + return; + + _pending.RemoveFirst(); + _queued.Remove(objectId); + if (result.Status == PluginItemCommandStatus.Started) + { + _inFlight = objectId; + _inFlightSeconds = 0d; + // Read after the request, so the request's own change to the + // slot is not taken for its answer. + _appraisalRevisionAtRequest = automation.Loot.Appraisal.Revision; + return; + } + + // Refused outright: the item is gone or cannot be appraised. + Finished?.Invoke(objectId, false); + } + } + + private void Finish(bool success) + { + uint objectId = _inFlight; + _inFlight = 0u; + _inFlightSeconds = 0d; + Finished?.Invoke(objectId, success); + } +} diff --git a/src/OpenAC.MosswartMassacre/Inventory/InventoryFeature.cs b/src/OpenAC.MosswartMassacre/Inventory/InventoryFeature.cs new file mode 100644 index 0000000..7df03bc --- /dev/null +++ b/src/OpenAC.MosswartMassacre/Inventory/InventoryFeature.cs @@ -0,0 +1,603 @@ +using AcDream.Plugin.Abstractions; +using Newtonsoft.Json; +using OpenAC.MosswartMassacre.Backend; + +namespace OpenAC.MosswartMassacre.Inventory; + +/// +/// The player's inventory on the backend: the whole inventory +/// (full_inventory) after login, at logoff and on +/// /mm sendinventory; every item entering, changing in or leaving it +/// (inventory_delta); and the Prismatic Taper count the telemetry +/// frame carries. +/// +/// +/// +/// Adds and removes go out at once. Changes to an item already tracked are +/// coalesced and sent as one update per changed item on a randomized +/// two-to-five-minute timer, re-rolled after every flush so a fleet of +/// clients never flushes in step. +/// +/// +/// Login waits until the inventory has stopped arriving (no new owned item +/// for ) and, while streaming, for the socket, so +/// the login inventory is not written into a closed socket. Until then new +/// items are tracked silently, as the original's login stream arrived before +/// its socket was open. Items worth appraising are appraised one at a time +/// through ; the original asked for all of them at +/// once. +/// +/// +/// A copy of the last inventory sent is kept in the character's storage, as +/// the original kept a file per character. It decides whether a login first +/// appraises everything and waits, and it keeps appraisal data from an +/// earlier session for items not yet appraised in this one. +/// +/// +internal sealed class InventoryFeature : IMmFeature +{ + public const string CacheKey = "inventory.json"; + + /// How long no new owned item must arrive before login counts the inventory as complete. + public static readonly TimeSpan SettleQuiet = TimeSpan.FromSeconds(3); + + /// The longest login waits for the inventory to stop arriving. + public static readonly TimeSpan SettleLimit = TimeSpan.FromSeconds(20); + + /// The longest the login inventory waits for the socket before it is written anyway. + public static readonly TimeSpan ConnectLimit = TimeSpan.FromSeconds(30); + + public const int FlushBaseMs = 120000; + public const int FlushJitterMs = 180000; + + private readonly MmContext _context; + private readonly InventoryReader _reader; + private readonly IdentifyQueue _identify; + private readonly Random _rng; + private readonly Action _onObjectChanged; + private readonly Action _onTick; + + // Live tracking (the original's live inventory tracker). + private readonly HashSet _tracked = []; + private readonly HashSet _dirty = []; + private IDisposable? _flushTimer; + + // Whole-inventory uploads (the original's inventory logger). + private readonly HashSet _requested = []; + private bool _waitingForLoginIds; + private bool _waitingForForcedIds; + + private bool _active; + private bool _settled; + private bool _loginUploadPending; + private double _quietSeconds; + private double _settleSeconds; + private double _connectSeconds; + + private bool _taperDirty = true; + private int _taperCount; + + public InventoryFeature(MmContext context, InventoryReader reader, IdentifyQueue identify, Random? rng = null) + { + _context = context; + _reader = reader; + _identify = identify; + _rng = rng ?? new Random(); + IsBackendReady = () => context.Backend.IsConnected; + _onObjectChanged = OnObjectChanged; + _onTick = OnTick; + _context.Host.Events.ObjectChanged += _onObjectChanged; + _context.Host.Events.Tick += _onTick; + + context.Commands.Register("sendinventory", _ => ForceInventoryUpload(), "Force full inventory upload"); + } + + /// + /// The Prismatic Tapers in the packs, for the telemetry frame's + /// prismatic_taper_count. Read it on the tick thread; it recounts + /// only after an item changed. + /// + public int PrismaticTaperCount + { + get + { + if (_active && _taperDirty) + { + _taperCount = _reader.CountPrismaticTapers(); + _taperDirty = false; + } + return _taperCount; + } + } + + /// True when the backend can take the login inventory. Tests replace it. + internal Func IsBackendReady { get; set; } + + internal bool IsSettled => _settled; + + internal int TrackedCount => _tracked.Count; + + internal int DirtyCount => _dirty.Count; + + public void OnLogin() + { + ResetState(); + _active = true; + _reader.Reset(); + _taperDirty = true; + + // The original's item events were wired before login, so every item + // of the login stream that was worth appraising was asked for. + foreach (PluginInventoryItem item in _reader.CaptureOwned()) + { + _tracked.Add(item.ObjectId); + if (InventoryReader.NeedsIdent(item.ObjectClass, item.Name) && !_reader.IsIdentified(item.ObjectId)) + _identify.Enqueue(item.ObjectId); + } + } + + /// + /// The original dumped the inventory at logoff. The root logs features + /// off in reverse order, the socket last, so this frame still goes out. + /// + public void OnLogoff() + { + OnHostLogoff(); + _active = false; + ResetState(); + } + + public void Dispose() + { + _context.Host.Events.ObjectChanged -= _onObjectChanged; + _context.Host.Events.Tick -= _onTick; + ResetState(); + } + + private void ResetState() + { + _flushTimer?.Dispose(); + _flushTimer = null; + _tracked.Clear(); + _dirty.Clear(); + _requested.Clear(); + _identify.Clear(); + _waitingForLoginIds = false; + _waitingForForcedIds = false; + _settled = false; + _loginUploadPending = false; + _quietSeconds = 0d; + _settleSeconds = 0d; + _connectSeconds = 0d; + } + + private bool InventoryLog => _context.Settings.Current.InventoryLog; + + private void OnTick(double deltaSeconds) + { + if (!_active) + return; + + _identify.Pump(deltaSeconds); + + if (!_settled) + { + _quietSeconds += deltaSeconds; + _settleSeconds += deltaSeconds; + if (_quietSeconds >= SettleQuiet.TotalSeconds || _settleSeconds >= SettleLimit.TotalSeconds) + Settle(); + return; + } + + if (_loginUploadPending) + { + _connectSeconds += deltaSeconds; + if (!_context.Settings.Current.WebSocketEnabled || IsBackendReady() || _connectSeconds >= ConnectLimit.TotalSeconds) + { + _loginUploadPending = false; + RunLoginUpload(); + } + return; + } + + if ((_waitingForLoginIds || _waitingForForcedIds) && _identify.IsIdle) + CompleteWaitingUpload(); + } + + /// The inventory has arrived: start live tracking and the login upload. + private void Settle() + { + _settled = true; + foreach (PluginInventoryItem item in _reader.CaptureOwned()) + _tracked.Add(item.ObjectId); + ArmFlushTimer(); + _loginUploadPending = true; + _connectSeconds = 0d; + } + + /// The original's inventory logger at login. + private void RunLoginUpload() + { + if (!InventoryLog) + return; + + if (ReadCache() is null) + { + _context.Chat.Write("Requesting id information for all armor/weapon inventory. This will take a few minutes..."); + foreach (PluginInventoryItem item in _reader.CaptureOwned()) + { + if (!_reader.IsIdentified(item.ObjectId) && InventoryReader.NeedsIdent(item.ObjectClass, item.Name)) + _identify.Enqueue(item.ObjectId); + } + _waitingForLoginIds = true; + } + else + { + DumpInventory(requestIdsIfMissing: true); + } + } + + /// + /// Every appraisal asked for has finished. The original waited until + /// every item worth appraising had been appraised, so one item the server + /// would not appraise held the upload back for good; this sends once the + /// requests have all been answered or given up. + /// + private void CompleteWaitingUpload() + { + bool wasForceUpload = _waitingForForcedIds; + _waitingForLoginIds = false; + _waitingForForcedIds = false; + DumpInventory(); + if (wasForceUpload) + _context.Chat.Write("[INV] All items identified. Inventory upload completed."); + else + _context.Chat.Write("Requesting id information for all armor/weapon inventory completed. Log file written."); + } + + /// /mm sendinventory: appraise what needs it, wait, then send everything. + internal void ForceInventoryUpload() + { + try + { + if (!InventoryLog) + { + _context.Chat.Write("[INV] Inventory logging is disabled"); + return; + } + + if (!_context.Settings.Current.WebSocketEnabled) + { + _context.Chat.Write("[INV] WebSocket streaming is disabled"); + return; + } + + if (!_active) + { + _context.Chat.Write("[INV] Inventory system not initialized"); + return; + } + + int needsId = 0; + int totalItems = 0; + foreach (PluginInventoryItem item in _reader.CaptureOwned()) + { + totalItems++; + if (!_reader.IsIdentified(item.ObjectId) && InventoryReader.NeedsIdent(item.ObjectClass, item.Name)) + { + _identify.Enqueue(item.ObjectId); + needsId++; + } + } + + if (needsId > 0) + { + _waitingForForcedIds = true; + _context.Chat.Write($"[INV] Scanning {totalItems} items, requesting ID for {needsId}. Waiting for identification..."); + } + else + { + _context.Chat.Write($"[INV] All {totalItems} items already identified. Sending..."); + DumpInventory(); + _context.Chat.Write("[INV] Inventory upload completed"); + } + } + catch (Exception ex) + { + _context.Chat.Write($"[INV] Force upload failed: {ex.Message}"); + } + } + + private void OnHostLogoff() + { + if (!_active || !InventoryLog) + return; + try + { + DumpInventory(); + } + catch (Exception ex) + { + _context.Chat.Write($"[INV] {ex}"); + } + } + + /// + /// The original's inventory dump: every owned item, merged with what the + /// saved copy knew about it, saved, and sent as full_inventory. + /// + internal void DumpInventory(bool requestIdsIfMissing = false) + { + List previouslySaved = ReadCache() ?? []; + + var currentList = new List(); + foreach (PluginInventoryItem item in _reader.CaptureOwned()) + { + MyWorldObject current = _reader.Build(item); + bool needsId = requestIdsIfMissing + && !current.HasIdData + && InventoryReader.NeedsIdent(item.ObjectClass, item.Name); + + MyWorldObject? previous = previouslySaved.Find( + prev => prev.Id == current.Id && prev.ObjectClass == current.ObjectClass); + if (previous is not null) + { + if (needsId && !previous.HasIdData) + { + _identify.Enqueue(item.ObjectId); + currentList.Add(current); + } + else + { + currentList.Add(DecalItemBuilder.Combine(previous, current)); + } + continue; + } + + if (needsId) + _identify.Enqueue(item.ObjectId); + currentList.Add(current); + } + + WriteCache(currentList); + + if (_context.Settings.Current.WebSocketEnabled) + { + _context.Send(new + { + type = "full_inventory", + timestamp = Wire.Timestamp(_context.Clock), + character_name = _context.CharacterName, + item_count = currentList.Count, + items = currentList, + }); + _context.Chat.Write("Inventory sent to MosswartOverlord"); + } + } + + private List? ReadCache() + { + IPluginStorage? scope = CacheScope(); + string? text = scope?.ReadText(CacheKey); + if (text is null) + return null; + try + { + return JsonConvert.DeserializeObject>(text) ?? []; + } + catch (JsonException) + { + _context.Chat.Write("Inventory file is corrupt."); + return []; + } + } + + private void WriteCache(List items) + { + IPluginStorage? scope = CacheScope(); + if (scope is null) + return; + try + { + scope.WriteText(CacheKey, JsonConvert.SerializeObject(items, Formatting.Indented)); + } + catch (Exception ex) + { + _context.Host.Log.Warn($"The inventory copy could not be saved: {ex.Message}"); + } + } + + private IPluginStorage? CacheScope() + { + IPluginStorage storage = _context.Host.Storage; + return storage.IsAvailable + ? storage.OpenScope(PluginStorageScope.Character(_context.CharacterName)) + : null; + } + + private void OnObjectChanged(PluginObjectChange change) + { + if (!_active) + return; + try + { + if (change.Kind == PluginObjectChangeKind.IdentReceived) + { + _reader.NoteAppraisal(change.ObjectId); + _identify.OnIdentReceived(change.ObjectId); + } + + PluginWorldObject? current = change.Current; + if (change.Kind == PluginObjectChangeKind.Released + || current is null + || string.Equals(current.Value.Name, InventoryReader.PrismaticTaper, StringComparison.OrdinalIgnoreCase)) + { + _taperDirty = true; + } + + if (change.Kind == PluginObjectChangeKind.Released) + { + OnRelease(change.ObjectId); + return; + } + if (current is not { } wo) + return; + + if (!_settled) + { + OnLoginStreamItem(wo); + return; + } + + if (change.Kind == PluginObjectChangeKind.Created) + OnCreate(wo); + else + OnChange(wo); + + RequestIdForMainPackItem(wo); + } + catch (Exception ex) + { + _context.Host.Log.Warn($"[LiveInv] Error handling an item change: {ex.Message}"); + } + } + + /// An item of the login stream: tracked without an add, appraised if worth it. + private void OnLoginStreamItem(PluginWorldObject wo) + { + if (!_reader.IsPlayerInventory(wo.ContainerObjectId, wo.WielderObjectId)) + return; + if (_tracked.Add(wo.ObjectId)) + _quietSeconds = 0d; + if (!IsIdentified(wo) && InventoryReader.NeedsIdent(wo.ObjectClass, wo.Name)) + _identify.Enqueue(wo.ObjectId); + } + + private void OnCreate(PluginWorldObject wo) + { + if (!_reader.IsPlayerInventory(wo.ContainerObjectId, wo.WielderObjectId)) + return; + if (_tracked.Contains(wo.ObjectId)) + return; + AddTracked(wo); + } + + private void OnChange(PluginWorldObject wo) + { + if (!_reader.IsPlayerInventory(wo.ContainerObjectId, wo.WielderObjectId)) + { + // The item left the inventory. + if (_tracked.Remove(wo.ObjectId)) + SendRemove(wo.ObjectId); + return; + } + + if (!_tracked.Contains(wo.ObjectId)) + AddTracked(wo); + else + _dirty.Add(wo.ObjectId); + } + + private void OnRelease(uint objectId) + { + if (!_settled) + { + _tracked.Remove(objectId); + return; + } + if (_tracked.Remove(objectId)) + SendRemove(objectId); + } + + private void AddTracked(PluginWorldObject wo) + { + _tracked.Add(wo.ObjectId); + if (InventoryReader.TryFind(_reader.CaptureOwned(), wo.ObjectId, out PluginInventoryItem item)) + SendDelta("add", _reader.Build(item)); + + if (!IsIdentified(wo) && InventoryReader.NeedsIdent(wo.ObjectClass, wo.Name)) + _identify.Enqueue(wo.ObjectId); + } + + /// + /// The inventory logger's own request: an unappraised item that lands in + /// the main pack (or is wielded) is appraised once. + /// + private void RequestIdForMainPackItem(PluginWorldObject wo) + { + if (!InventoryLog || _waitingForLoginIds || _waitingForForcedIds) + return; + uint container = wo.ContainerObjectId != 0u ? wo.ContainerObjectId : wo.WielderObjectId; + if (!IsIdentified(wo) + && InventoryReader.NeedsIdent(wo.ObjectClass, wo.Name) + && container == _reader.CharacterId + && _requested.Add(wo.ObjectId)) + { + _identify.Enqueue(wo.ObjectId); + } + } + + private static bool IsIdentified(PluginWorldObject wo) => wo.HasAppraisalData && wo.LastIdTime != 0; + + private void ArmFlushTimer() + { + _flushTimer?.Dispose(); + _flushTimer = _context.Scheduler.Delay(TimeSpan.FromMilliseconds(NextFlushIntervalMs()), FlushDirtyItems); + } + + /// Two minutes plus up to three more, re-rolled for every flush. + internal int NextFlushIntervalMs() => FlushBaseMs + _rng.Next(0, FlushJitterMs + 1); + + /// Sends the current state of every item that changed since the last flush. + internal void FlushDirtyItems() + { + if (!_active) + return; + ArmFlushTimer(); + if (_dirty.Count == 0) + return; + + var ids = new List(_dirty); + _dirty.Clear(); + IReadOnlyList owned = _reader.CaptureOwned(); + foreach (uint id in ids) + { + try + { + // An item that left since it was marked has had its remove. + if (!_tracked.Contains(id)) + continue; + if (!InventoryReader.TryFind(owned, id, out PluginInventoryItem item) + || !_reader.IsPlayerInventory(item.ContainerObjectId, item.WielderObjectId)) + { + continue; + } + SendDelta("update", _reader.Build(item)); + } + catch (Exception ex) + { + _context.Host.Log.Warn($"[LiveInv] Error flushing item {id}: {ex.Message}"); + } + } + } + + private void SendDelta(string action, MyWorldObject item) => + _context.Send(new + { + type = "inventory_delta", + timestamp = Wire.Timestamp(_context.Clock), + character_name = _context.CharacterName, + action = action, + item = item, + }); + + private void SendRemove(uint objectId) => + _context.Send(new + { + type = "inventory_delta", + timestamp = Wire.Timestamp(_context.Clock), + character_name = _context.CharacterName, + action = "remove", + item_id = unchecked((int)objectId), + }); +} diff --git a/src/OpenAC.MosswartMassacre/Inventory/InventoryReader.cs b/src/OpenAC.MosswartMassacre/Inventory/InventoryReader.cs new file mode 100644 index 0000000..c5712bb --- /dev/null +++ b/src/OpenAC.MosswartMassacre/Inventory/InventoryReader.cs @@ -0,0 +1,146 @@ +using AcDream.Plugin.Abstractions; + +namespace OpenAC.MosswartMassacre.Inventory; + +/// +/// Reads the player's items from the host and turns them into item records. +/// Tick thread only, like every host read. +/// +/// +/// The original's appraisal time was the wall clock in Unix seconds when the +/// appraisal arrived. The host keeps its own session clock instead, so the +/// reader stamps the wall clock the first time it sees each new appraisal of +/// an item and reports that stamp from then on. +/// +internal sealed class InventoryReader +{ + public const string PrismaticTaper = "Prismatic Taper"; + + private readonly MmContext _context; + private readonly IItemHeaderSource _headers; + private readonly Dictionary _idStamps = []; + + public InventoryReader(MmContext context, IItemHeaderSource headers) + { + _context = context; + _headers = headers; + } + + public uint CharacterId => _context.Automation.Character.ObjectId; + + /// Forgets the appraisal stamps, for a new login. + public void Reset() => _idStamps.Clear(); + + public IReadOnlyList CaptureOwned() => + _context.Automation.Items.CaptureOwnedItems(); + + public static bool TryFind(IReadOnlyList items, uint objectId, out PluginInventoryItem item) + { + foreach (PluginInventoryItem candidate in items) + { + if (candidate.ObjectId != objectId) + continue; + item = candidate; + return true; + } + item = default; + return false; + } + + /// + /// True once the item has been appraised this session. Any property data + /// makes the host report appraisal data, so the appraisal time is what + /// tells a real appraisal from a lone property update. + /// + public bool IsIdentified(uint objectId) => + _context.Automation.Objects.TryGet(objectId, out PluginWorldObject world) + && world.HasAppraisalData + && world.LastIdTime != 0; + + /// + /// Stamps an appraisal as it arrives, so the record carries the time it + /// came in rather than the time the record was first built. + /// + public void NoteAppraisal(uint objectId) + { + if (_context.Automation.Objects.TryGet(objectId, out PluginWorldObject world) && world.LastIdTime != 0) + Stamp(objectId, world.LastIdTime); + } + + /// The item record for one owned item, as it stands now. + public MyWorldObject Build(in PluginInventoryItem item) + { + bool identified = false; + int lastIdTime = 0; + if (_context.Automation.Objects.TryGet(item.ObjectId, out PluginWorldObject world) + && world.HasAppraisalData + && world.LastIdTime != 0) + { + identified = true; + lastIdTime = Stamp(item.ObjectId, world.LastIdTime); + } + + PluginItemProperties? properties = + _context.Automation.Items.TryCaptureProperties(item.ObjectId, out PluginItemProperties captured) + ? captured + : null; + + return DecalItemBuilder.Create(new ItemSource( + item, properties, identified, lastIdTime, _headers.Read(item))); + } + + /// + /// The original's test for an item worth appraising: armor, clothing, + /// weapons, casters, jewelry, Aetheria and summoning essences. + /// + public static bool NeedsIdent(PluginObjectClass objectClass, string? name) => + objectClass == PluginObjectClass.Armor + || objectClass == PluginObjectClass.Clothing + || objectClass == PluginObjectClass.MeleeWeapon + || objectClass == PluginObjectClass.MissileWeapon + || objectClass == PluginObjectClass.WandStaffOrb + || objectClass == PluginObjectClass.Jewelry + || (objectClass == PluginObjectClass.Gem && !string.IsNullOrEmpty(name) && name.Contains("Aetheria", StringComparison.Ordinal)) + || (objectClass == PluginObjectClass.Misc && !string.IsNullOrEmpty(name) && name.Contains("Essence", StringComparison.Ordinal)); + + /// + /// The original's inventory test: the item sits in (or is wielded by) the + /// character, or sits in a pack the character holds. + /// + public bool IsPlayerInventory(uint containerObjectId, uint wielderObjectId) + { + uint characterId = CharacterId; + uint container = containerObjectId != 0u ? containerObjectId : wielderObjectId; + if (characterId == 0u || container == 0u) + return false; + if (container == characterId) + return true; + return _context.Automation.Objects.TryGet(container, out PluginWorldObject pack) + && pack.ObjectClass == PluginObjectClass.Container + && pack.ContainerObjectId == characterId; + } + + /// The number of Prismatic Tapers in the packs. + public int CountPrismaticTapers() + { + int count = 0; + foreach (PluginInventoryItem item in CaptureOwned()) + { + if (string.Equals(item.Name, PrismaticTaper, StringComparison.OrdinalIgnoreCase) + && IsPlayerInventory(item.ContainerObjectId, 0u)) + { + count += item.StackSize; + } + } + return count; + } + + private int Stamp(uint objectId, int hostTime) + { + if (_idStamps.TryGetValue(objectId, out var stamp) && stamp.HostTime == hostTime) + return stamp.UnixSeconds; + int unix = unchecked((int)new DateTimeOffset(_context.Clock.UtcNow).ToUnixTimeSeconds()); + _idStamps[objectId] = (hostTime, unix); + return unix; + } +} diff --git a/src/OpenAC.MosswartMassacre/Inventory/ItemDictionaries.cs b/src/OpenAC.MosswartMassacre/Inventory/ItemDictionaries.cs new file mode 100644 index 0000000..b82efe4 --- /dev/null +++ b/src/OpenAC.MosswartMassacre/Inventory/ItemDictionaries.cs @@ -0,0 +1,441 @@ +namespace OpenAC.MosswartMassacre.Inventory; + +/// +/// The lookup tables the item record's getters read (material, skill, mastery +/// and item-set names; the item-spell effects behind the "buffed" and +/// "calced" numbers). Copied verbatim from the original plugin's shared code: +/// the getters' output is part of the wire, so a changed name or number here +/// changes a frame. +/// +internal static class ItemDictionaries +{ + /// + /// Returns a dictionary of skill ids vs names + /// + /// + public static readonly Dictionary SkillInfo = new Dictionary + { + // This list was taken from the Alinco source + { 0x1, "Axe" }, + { 0x2, "Bow" }, + { 0x3, "Crossbow" }, + { 0x4, "Dagger" }, + { 0x5, "Mace" }, + { 0x6, "Melee Defense" }, + { 0x7, "Missile Defense" }, + { 0x8, "Sling" }, + { 0x9, "Spear" }, + { 0xA, "Staff" }, + { 0xB, "Sword" }, + { 0xC, "Thrown Weapons" }, + { 0xD, "Unarmed Combat" }, + { 0xE, "Arcane Lore" }, + { 0xF, "Magic Defense" }, + { 0x10, "Mana Conversion" }, + { 0x12, "Item Tinkering" }, + { 0x13, "Assess Person" }, + { 0x14, "Deception" }, + { 0x15, "Healing" }, + { 0x16, "Jump" }, + { 0x17, "Lockpick" }, + { 0x18, "Run" }, + { 0x1B, "Assess Creature" }, + { 0x1C, "Weapon Tinkering" }, + { 0x1D, "Armor Tinkering" }, + { 0x1E, "Magic Item Tinkering" }, + { 0x1F, "Creature Enchantment" }, + { 0x20, "Item Enchantment" }, + { 0x21, "Life Magic" }, + { 0x22, "War Magic" }, + { 0x23, "Leadership" }, + { 0x24, "Loyalty" }, + { 0x25, "Fletching" }, + { 0x26, "Alchemy" }, + { 0x27, "Cooking" }, + { 0x28, "Salvaging" }, + { 0x29, "Two Handed Combat" }, + { 0x2A, "Gearcraft"}, + { 0x2B, "Void" }, + { 0x2C, "Heavy Weapons" }, + { 0x2D, "Light Weapons" }, + { 0x2E, "Finesse Weapons" }, + { 0x2F, "Missile Weapons" }, + { 0x30, "Shield" }, + { 0x31, "Dual Wield" }, + { 0x32, "Recklessness" }, + { 0x33, "Sneak Attack" }, + { 0x34, "Dirty Fighting" }, + { 0x35, "Challenge" }, + { 0x36, "Summoning" }, + }; + + /// + /// Returns a dictionary of mastery ids vs names + /// + /// + public static readonly Dictionary MasteryInfo = new Dictionary + { + { 1, "Unarmed Weapon" }, + { 2, "Sword" }, + { 3, "Axe" }, + { 4, "Mace" }, + { 5, "Spear" }, + { 6, "Dagger" }, + { 7, "Staff" }, + { 8, "Bow" }, + { 9, "Crossbow" }, + { 10, "Thrown" }, + { 11, "Two Handed Combat" }, + }; + + /// + /// Returns a dictionary of attribute set ids vs names + /// + /// + public static readonly Dictionary AttributeSetInfo = new Dictionary + { + // This list was taken from Virindi Tank Loot Editor + // 01 + { 02, "Test"}, + // 03 + { 04, "Carraida's Benediction"}, + { 05, "Noble Relic Set" }, + { 06, "Ancient Relic Set" }, + { 07, "Relic Alduressa Set" }, + { 08, "Shou-jen Set" }, + { 09, "Empyrean Rings Set" }, + { 10, "Arm, Mind, Heart Set" }, + { 11, "Coat of the Perfect Light Set" }, + { 12, "Leggings of Perfect Light Set" }, + { 13, "Soldier's Set" }, + { 14, "Adept's Set" }, + { 15, "Archer's Set" }, + { 16, "Defender's Set" }, + { 17, "Tinker's Set" }, + { 18, "Crafter's Set" }, + { 19, "Hearty Set" }, + { 20, "Dexterous Set" }, + { 21, "Wise Set" }, + { 22, "Swift Set" }, + { 23, "Hardenend Set" }, + { 24, "Reinforced Set" }, + { 25, "Interlocking Set" }, + { 26, "Flame Proof Set" }, + { 27, "Acid Proof Set" }, + { 28, "Cold Proof Set" }, + { 29, "Lightning Proof Set" }, + { 30, "Dedication Set" }, + { 31, "Gladiatorial Clothing Set" }, + { 32, "Ceremonial Clothing" }, + { 33, "Protective Clothing" }, + { 34, "Noobie Armor" }, + { 35, "Sigil of Defense" }, + { 36, "Sigil of Destruction" }, + { 37, "Sigil of Fury" }, + { 38, "Sigil of Growth" }, + { 39, "Sigil of Vigor" }, + { 40, "Heroic Protector Set" }, + { 41, "Heroic Destroyer Set" }, + { 42, "Olthoi Armor D Red" }, + { 43, "Olthoi Armor C Rat" }, + { 44, "Olthoi Armor C Red" }, + { 45, "Olthoi Armor D Rat" }, + { 46, "Upgraded Relic Alduressa Set" }, + { 47, "Upgraded Ancient Relic Set" }, + { 48, "Upgraded Noble Relic Set" }, + { 49, "Weave of Alchemy" }, + { 50, "Weave of Arcane Lore" }, + { 51, "Weave of Armor Tinkering" }, + { 52, "Weave of Assess Person" }, + { 53, "Weave of Light Weapons" }, + { 54, "Weave of Missile Weapons" }, + { 55, "Weave of Cooking" }, + { 56, "Weave of Creature Enchantment" }, + { 57, "Weave of Missile Weapons" }, + { 58, "Weave of Finesse" }, + { 59, "Weave of Deception" }, + { 60, "Weave of Fletching" }, + { 61, "Weave of Healing" }, + { 62, "Weave of Item Enchantment" }, + { 63, "Weave of Item Tinkering" }, + { 64, "Weave of Leadership" }, + { 65, "Weave of Life Magic" }, + { 66, "Weave of Loyalty" }, + { 67, "Weave of Light Weapons" }, + { 68, "Weave of Magic Defense" }, + { 69, "Weave of Magic Item Tinkering" }, + { 70, "Weave of Mana Conversion" }, + { 71, "Weave of Melee Defense" }, + { 72, "Weave of Missile Defense" }, + { 73, "Weave of Salvaging" }, + { 74, "Weave of Light Weapons" }, + { 75, "Weave of Light Weapons" }, + { 76, "Weave of Heavy Weapons" }, + { 77, "Weave of Missile Weapons" }, + { 78, "Weave of Two Handed Combat" }, + { 79, "Weave of Light Weapons" }, + { 80, "Weave of Void Magic" }, + { 81, "Weave of War Magic" }, + { 82, "Weave of Weapon Tinkering" }, + { 83, "Weave of Assess Creature " }, + { 84, "Weave of Dirty Fighting" }, + { 85, "Weave of Dual Wield" }, + { 86, "Weave of Recklessness" }, + { 87, "Weave of Shield" }, + { 88, "Weave of Sneak Attack" }, + { 89, "Ninja_New" }, + { 90, "Weave of Summoning" }, + + { 91, "Shrouded Soul" }, + { 92, "Darkened Mind" }, + { 93, "Clouded Spirit" }, + { 94, "Minor Stinging Shrouded Soul" }, + { 95, "Minor Sparking Shrouded Soul" }, + { 96, "Minor Smoldering Shrouded Soul" }, + { 97, "Minor Shivering Shrouded Soul" }, + { 98, "Minor Stinging Darkened Mind" }, + { 99, "Minor Sparking Darkened Mind" }, + + { 100, "Minor Smoldering Darkened Mind" }, + { 101, "Minor Shivering Darkened Mind" }, + { 102, "Minor Stinging Clouded Spirit" }, + { 103, "Minor Sparking Clouded Spirit" }, + { 104, "Minor Smoldering Clouded Spirit" }, + { 105, "Minor Shivering Clouded Spirit" }, + { 106, "Major Stinging Shrouded Soul" }, + { 107, "Major Sparking Shrouded Soul" }, + { 108, "Major Smoldering Shrouded Soul" }, + { 109, "Major Shivering Shrouded Soul" }, + + { 110, "Major Stinging Darkened Mind" }, + { 111, "Major Sparking Darkened Mind" }, + { 112, "Major Smoldering Darkened Mind" }, + { 113, "Major Shivering Darkened Mind" }, + { 114, "Major Stinging Clouded Spirit" }, + { 115, "Major Sparking Clouded Spirit" }, + { 116, "Major Smoldering Clouded Spirit" }, + { 117, "Major Shivering Clouded Spirit" }, + { 118, "Blackfire Stinging Shrouded Soul" }, + { 119, "Blackfire Sparking Shrouded Soul" }, + + { 120, "Blackfire Smoldering Shrouded Soul" }, + { 121, "Blackfire Shivering Shrouded Soul" }, + { 122, "Blackfire Stinging Darkened Mind" }, + { 123, "Blackfire Sparking Darkened Mind" }, + { 124, "Blackfire Smoldering Darkened Mind" }, + { 125, "Blackfire Shivering Darkened Mind" }, + { 126, "Blackfire Stinging Clouded Spirit" }, + { 127, "Blackfire Sparking Clouded Spirit" }, + { 128, "Blackfire Smoldering Clouded Spirit" }, + { 129, "Blackfire Shivering Clouded Spirit" }, + + { 130, "Shimmering Shadows" }, + + { 131, "Brown Society Locket" }, + { 132, "Yellow Society Locket" }, + { 133, "Red Society Band" }, + { 134, "Green Society Band" }, + { 135, "Purple Society Band" }, + { 136, "Blue Society Band" }, + + { 137, "Gauntlet Garb" }, + + { 138, "UNKNOWN_138" }, // Possibly Paragon Missile Weapons + { 139, "UNKNOWN_139" }, // Possibly Paragon Casters + { 140, "UNKNOWN_140" }, // Possibly Paragon Melee Weapons + }; + + /// + /// Returns a dictionary of material ids vs names + /// + /// + public static readonly Dictionary MaterialInfo = new Dictionary + { + { 1, "Ceramic" }, + { 2, "Porcelain" }, + // 3 + { 4, "Linen" }, + { 5, "Satin" }, + { 6, "Silk" }, + { 7, "Velvet" }, + { 8, "Wool" }, + // 9 + { 10, "Agate" }, + { 11, "Amber" }, + { 12, "Amethyst" }, + { 13, "Aquamarine" }, + { 14, "Azurite" }, + { 15, "Black Garnet" }, + { 16, "Black Opal" }, + { 17, "Bloodstone" }, + { 18, "Carnelian" }, + { 19, "Citrine" }, + { 20, "Diamond" }, + { 21, "Emerald" }, + { 22, "Fire Opal" }, + { 23, "Green Garnet" }, + { 24, "Green Jade" }, + { 25, "Hematite" }, + { 26, "Imperial Topaz" }, + { 27, "Jet" }, + { 28, "Lapis Lazuli" }, + { 29, "Lavender Jade" }, + { 30, "Malachite" }, + { 31, "Moonstone" }, + { 32, "Onyx" }, + { 33, "Opal" }, + { 34, "Peridot" }, + { 35, "Red Garnet" }, + { 36, "Red Jade" }, + { 37, "Rose Quartz" }, + { 38, "Ruby" }, + { 39, "Sapphire" }, + { 40, "Smokey Quartz" }, + { 41, "Sunstone" }, + { 42, "Tiger Eye" }, + { 43, "Tourmaline" }, + { 44, "Turquoise" }, + { 45, "White Jade" }, + { 46, "White Quartz" }, + { 47, "White Sapphire" }, + { 48, "Yellow Garnet" }, + { 49, "Yellow Topaz" }, + { 50, "Zircon" }, + { 51, "Ivory" }, + { 52, "Leather" }, + { 53, "Armoredillo Hide" }, + { 54, "Gromnie Hide" }, + { 55, "Reed Shark Hide" }, + // 56 + { 57, "Brass" }, + { 58, "Bronze" }, + { 59, "Copper" }, + { 60, "Gold" }, + { 61, "Iron" }, + { 62, "Pyreal" }, + { 63, "Silver" }, + { 64, "Steel" }, + // 65 + { 66, "Alabaster" }, + { 67, "Granite" }, + { 68, "Marble" }, + { 69, "Obsidian" }, + { 70, "Sandstone" }, + { 71, "Serpentine" }, + { 73, "Ebony" }, + { 74, "Mahogany" }, + { 75, "Oak" }, + { 76, "Pine" }, + { 77, "Teak" }, + }; + + public struct SpellInfo + where T : struct + { + public readonly int Key; + public readonly T Change; + public readonly T Bonus; + + public SpellInfo(int key, T change, T bonus = default) + { + Key = key; + Change = change; + Bonus = bonus; + } + } + + // Taken from Decal.Adapter.Wrappers.LongValueKey + const int LongValueKey_MaxDamage = 218103842; + const int LongValueKey_ArmorLevel = 28; + + public static readonly Dictionary> LongValueKeySpellEffects = new Dictionary>() + { + // In 2012 they removed these item spells and converted them to auras that are cast on the player, not on the item. + { 1616, new SpellInfo(LongValueKey_MaxDamage, 20)}, // Blood Drinker VI + { 2096, new SpellInfo(LongValueKey_MaxDamage, 22)}, // Infected Caress + //{ 5183, new SpellInfo(LongValueKey_MaxDamage, 22)}, // Incantation of Blood Drinker Pre Feb-2013 + //{ 4395, new SpellInfo(LongValueKey_MaxDamage, 24, 2)}, // Incantation of Blood Drinker, this spell on the item adds 2 more points of damage over a user casted 8 Pre Feb-2013 + { 5183, new SpellInfo(LongValueKey_MaxDamage, 24)}, // Incantation of Blood Drinker Post Feb-2013 + { 4395, new SpellInfo(LongValueKey_MaxDamage, 24)}, // Incantation of Blood Drinker Post Feb-2013 + + { 2598, new SpellInfo(LongValueKey_MaxDamage, 2, 2)}, // Minor Blood Thirst + { 2586, new SpellInfo(LongValueKey_MaxDamage, 4, 4)}, // Major Blood Thirst + { 4661, new SpellInfo(LongValueKey_MaxDamage, 7, 7)}, // Epic Blood Thirst + { 6089, new SpellInfo(LongValueKey_MaxDamage, 10, 10)}, // Legendary Blood Thirst + + { 3688, new SpellInfo(LongValueKey_MaxDamage, 300)}, // Prodigal Blood Drinker + + + { 1486, new SpellInfo(LongValueKey_ArmorLevel, 200)}, // Impenetrability VI + { 2108, new SpellInfo(LongValueKey_ArmorLevel, 220)}, // Brogard's Defiance + { 4407, new SpellInfo(LongValueKey_ArmorLevel, 240)}, // Incantation of Impenetrability + + { 2604, new SpellInfo(LongValueKey_ArmorLevel, 20, 20)}, // Minor Impenetrability + { 2592, new SpellInfo(LongValueKey_ArmorLevel, 40, 40)}, // Major Impenetrability + { 4667, new SpellInfo(LongValueKey_ArmorLevel, 60, 60)}, // Epic Impenetrability + { 6095, new SpellInfo(LongValueKey_ArmorLevel, 80, 80)}, // Legendary Impenetrability + }; + + // Taken from Decal.Adapter.Wrappers.DoubleValueKey + const int DoubleValueKey_ElementalDamageVersusMonsters = 152; + const int DoubleValueKey_AttackBonus = 167772172; + const int DoubleValueKey_MeleeDefenseBonus = 29; + const int DoubleValueKey_ManaCBonus = 144; + + public static readonly Dictionary> DoubleValueKeySpellEffects = new Dictionary>() + { + // In 2012 they removed these item spells and converted them to auras that are cast on the player, not on the item. + { 3258, new SpellInfo(DoubleValueKey_ElementalDamageVersusMonsters, .06)}, // Spirit Drinker VI + { 3259, new SpellInfo(DoubleValueKey_ElementalDamageVersusMonsters, .07)}, // Infected Spirit Caress + //{ 5182, new SpellInfo(DoubleValueKey_ElementalDamageVersusMonsters, .07)}, // Incantation of Spirit Drinker Pre Feb-2013 + //{ 4414, new SpellInfo(DoubleValueKey_ElementalDamageVersusMonsters, .08, .01)}, // Incantation of Spirit Drinker, this spell on the item adds 1 more % of damage over a user casted 8 Pre Feb-2013 + { 5182, new SpellInfo(DoubleValueKey_ElementalDamageVersusMonsters, .08)}, // Incantation of Spirit Drinker Post Feb-2013 + { 4414, new SpellInfo(DoubleValueKey_ElementalDamageVersusMonsters, .08)}, // Incantation of Spirit Drinker, this spell on the item adds 1 more % of damage over a user casted 8 Post Feb-2013 + + { 3251, new SpellInfo(DoubleValueKey_ElementalDamageVersusMonsters, .01, .01)}, // Minor Spirit Thirst + { 3250, new SpellInfo(DoubleValueKey_ElementalDamageVersusMonsters, .03, .03)}, // Major Spirit Thirst + { 4670, new SpellInfo(DoubleValueKey_ElementalDamageVersusMonsters, .05, .05)}, // Epic Spirit Thirst + { 6098, new SpellInfo(DoubleValueKey_ElementalDamageVersusMonsters, .07, .07)}, // Legendary Spirit Thirst + + { 3735, new SpellInfo(DoubleValueKey_ElementalDamageVersusMonsters, .15)}, // Prodigal Spirit Drinker + + + // In 2012 they removed these item spells and converted them to auras that are cast on the player, not on the item. + { 1592, new SpellInfo(DoubleValueKey_AttackBonus, .15)}, // Heart Seeker VI + { 2106, new SpellInfo(DoubleValueKey_AttackBonus, .17)}, // Elysa's Sight + { 4405, new SpellInfo(DoubleValueKey_AttackBonus, .20)}, // Incantation of Heart Seeker + + { 2603, new SpellInfo(DoubleValueKey_AttackBonus, .03, .03)}, // Minor Heart Thirst + { 2591, new SpellInfo(DoubleValueKey_AttackBonus, .05, .05)}, // Major Heart Thirst + { 4666, new SpellInfo(DoubleValueKey_AttackBonus, .07, .07)}, // Epic Heart Thirst + { 6094, new SpellInfo(DoubleValueKey_AttackBonus, .09, .09)}, // Legendary Heart Thirst + + + // In 2012 they removed these item spells and converted them to auras that are cast on the player, not on the item. + { 1605, new SpellInfo(DoubleValueKey_MeleeDefenseBonus, .15)}, // Defender VI + { 2101, new SpellInfo(DoubleValueKey_MeleeDefenseBonus, .17)}, // Cragstone's Will + //{ 4400, new SpellInfo(DoubleValueKey_MeleeDefenseBonus, .17)}, // Incantation of Defender Pre Feb-2013 + { 4400, new SpellInfo(DoubleValueKey_MeleeDefenseBonus, .20)}, // Incantation of Defender Post Feb-2013 + + { 2600, new SpellInfo(DoubleValueKey_MeleeDefenseBonus, .03, .03)}, // Minor Defender + { 3985, new SpellInfo(DoubleValueKey_MeleeDefenseBonus, .04, .04)}, // Mukkir Sense + { 2588, new SpellInfo(DoubleValueKey_MeleeDefenseBonus, .05, .05)}, // Major Defender + { 4663, new SpellInfo(DoubleValueKey_MeleeDefenseBonus, .07, .07)}, // Epic Defender + { 6091, new SpellInfo(DoubleValueKey_MeleeDefenseBonus, .09, .09)}, // Legendary Defender + + { 3699, new SpellInfo(DoubleValueKey_MeleeDefenseBonus, .25)}, // Prodigal Defender + + + // In 2012 they removed these item spells and converted them to auras that are cast on the player, not on the item. + { 1480, new SpellInfo(DoubleValueKey_ManaCBonus, 1.60)}, // Hermetic Link VI + { 2117, new SpellInfo(DoubleValueKey_ManaCBonus, 1.70)}, // Mystic's Blessing + { 4418, new SpellInfo(DoubleValueKey_ManaCBonus, 1.80)}, // Incantation of Hermetic Link + + { 3201, new SpellInfo(DoubleValueKey_ManaCBonus, 1.05, 1.05)}, // Feeble Hermetic Link + { 3199, new SpellInfo(DoubleValueKey_ManaCBonus, 1.10, 1.10)}, // Minor Hermetic Link + { 3202, new SpellInfo(DoubleValueKey_ManaCBonus, 1.15, 1.15)}, // Moderate Hermetic Link + { 3200, new SpellInfo(DoubleValueKey_ManaCBonus, 1.20, 1.20)}, // Major Hermetic Link + { 6086, new SpellInfo(DoubleValueKey_ManaCBonus, 1.25, 1.25)}, // Epic Hermetic Link + { 6087, new SpellInfo(DoubleValueKey_ManaCBonus, 1.30, 1.30)}, // Legendary Hermetic Link + }; +} diff --git a/src/OpenAC.MosswartMassacre/Inventory/ItemHeaderSource.cs b/src/OpenAC.MosswartMassacre/Inventory/ItemHeaderSource.cs new file mode 100644 index 0000000..329374a --- /dev/null +++ b/src/OpenAC.MosswartMassacre/Inventory/ItemHeaderSource.cs @@ -0,0 +1,76 @@ +using AcDream.Plugin.Abstractions; + +namespace OpenAC.MosswartMassacre.Inventory; + +/// +/// The header words the original's key tables carried for every item that +/// the plugin contract does not hand a plugin yet. Each one that is null is +/// left out of the item record, which is what the original sent for an item +/// whose framework lacked the value. +/// +/// +/// +/// matters beyond its own key: it says which +/// optional header fields the server sent. While it is null the builder +/// decides presence from the field's value instead (present when non-zero), +/// which is wrong for a field the server sent as zero, most often the +/// equipable-slots word. +/// +/// +/// The physics words come from the object's physics description, which +/// the contract does not expose at all. +/// +/// +internal readonly record struct ItemHeaderWords +{ + /// The object header's first flag word (key 218103832). + public uint? WeenieHeaderFlags { get; init; } + + /// The object header's second flag word (key 218103833), when the header has one. + public uint? WeenieHeaderFlags2 { get; init; } + + /// The physics description's flag word (key 218103847). + public uint? PhysicsDescriptionFlags { get; init; } + + /// The physics word the framework filed under key 218103831. + public uint? PhysicsWord { get; init; } + + /// The model setup id (key 218103830). + public uint? SetupId { get; init; } + + /// + /// The value the framework filed under its scale key (167772170). It was + /// 0.0 in every recorded item whose physics description carried a scale. + /// + public double? Scale { get; init; } + + /// The object a wielded item hangs from (key 218103818). + public uint? ParentObjectId { get; init; } + + /// Where on its parent a wielded item hangs (key 218103819). + public int? ParentLocation { get; init; } + + /// The header's hook type (key 218103828). + public int? HookType { get; init; } +} + +/// Reads the header words for one owned item. +internal interface IItemHeaderSource +{ + ItemHeaderWords Read(in PluginInventoryItem item); +} + +/// +/// The contract this plugin builds against exposes none of these words, so +/// every one is absent. Replace this when the contract grows them. +/// +internal sealed class HostItemHeaderSource : IItemHeaderSource +{ + public static HostItemHeaderSource Instance { get; } = new(); + + private HostItemHeaderSource() + { + } + + public ItemHeaderWords Read(in PluginInventoryItem item) => default; +} diff --git a/src/OpenAC.MosswartMassacre/Inventory/MyWorldObject.cs b/src/OpenAC.MosswartMassacre/Inventory/MyWorldObject.cs new file mode 100644 index 0000000..81d4000 --- /dev/null +++ b/src/OpenAC.MosswartMassacre/Inventory/MyWorldObject.cs @@ -0,0 +1,313 @@ +using System.Globalization; + +namespace OpenAC.MosswartMassacre.Inventory; + +/// +/// One item as the backend receives it: the original plugin's item record, +/// carried over member for member because it is serialized as-is into +/// full_inventory and inventory_delta. +/// +/// +/// +/// Newtonsoft writes the public fields first, in declaration order, then every +/// public getter, in declaration order. The getters are part of the wire: the +/// backend reads several of them (Material, EquipSkill, MaxDamage, WieldLevel, +/// ...). Keep every member, its type, its name and its position exactly as +/// they are; a double getter must stay a double so -1 is written +/// -1.0. +/// +/// +/// The four tables are keyed the way the original's client framework keyed +/// them: raw game property ids mixed with that framework's own pseudo keys +/// (see ). fills them. +/// +/// +internal sealed class MyWorldObject +{ + public bool HasIdData; + public int Id; + public int LastIdTime; + public int ObjectClass; + public int Icon; + public int ContainerId; + + public Dictionary BoolValues = []; + public Dictionary DoubleValues = []; + public Dictionary IntValues = []; + public Dictionary StringValues = []; + + public List ActiveSpells = []; + public List Spells = []; + + /// + /// Copies every entry of the given tables over this record's, keeping any + /// key only this record has. The original guarded each copy with a check + /// against the table it was iterating, which is always true, so every + /// incoming entry simply overwrites. + /// + public void AddTo( + IDictionary boolValues, + IDictionary doubleValues, + IDictionary intValues, + IDictionary stringValues) + { + foreach (KeyValuePair kvp in boolValues) + BoolValues[kvp.Key] = kvp.Value; + foreach (KeyValuePair kvp in doubleValues) + DoubleValues[kvp.Key] = kvp.Value; + foreach (KeyValuePair kvp in intValues) + IntValues[kvp.Key] = kvp.Value; + foreach (KeyValuePair kvp in stringValues) + StringValues[kvp.Key] = kvp.Value; + } + + public string? Material { get { if (IntValues.ContainsKey(131)) return ItemDictionaries.MaterialInfo.ContainsKey(IntValues[131]) ? ItemDictionaries.MaterialInfo[IntValues[131]] : IntValues[131].ToString(CultureInfo.InvariantCulture); return null; } } + + public string? Name { get { return StringValues.ContainsKey(1) ? StringValues[1] : null; } } + + public string? EquipSkill { get { if (IntValues.ContainsKey(218103840)) return ItemDictionaries.SkillInfo.ContainsKey(IntValues[218103840]) ? ItemDictionaries.SkillInfo[IntValues[218103840]] : IntValues[218103840].ToString(CultureInfo.InvariantCulture); return null; } } + + public string? Mastery { get { if (IntValues.ContainsKey(353)) return ItemDictionaries.MasteryInfo.ContainsKey(IntValues[353]) ? ItemDictionaries.MasteryInfo[IntValues[353]] : IntValues[353].ToString(CultureInfo.InvariantCulture); return null; } } + + public string? ItemSet { get { if (IntValues.ContainsKey(265)) return ItemDictionaries.AttributeSetInfo.ContainsKey(IntValues[265]) ? ItemDictionaries.AttributeSetInfo[IntValues[265]] : IntValues[265].ToString(CultureInfo.InvariantCulture); return null; } } + + public int ArmorLevel { get { return IntValues.ContainsKey(28) ? IntValues[28] : -1; } } + + public string? Imbue + { + get + { + if (!IntValues.ContainsKey(179) || IntValues[179] == 0) return null; + + string retVal = string.Empty; + if ((IntValues[179] & 1) == 1) retVal += " CS"; + if ((IntValues[179] & 2) == 2) retVal += " CB"; + if ((IntValues[179] & 4) == 4) retVal += " AR"; + if ((IntValues[179] & 8) == 8) retVal += " SlashRend"; + if ((IntValues[179] & 16) == 16) retVal += " PierceRend"; + if ((IntValues[179] & 32) == 32) retVal += " BludgeRend"; + if ((IntValues[179] & 64) == 64) retVal += " AcidRend"; + if ((IntValues[179] & 128) == 128) retVal += " FrostRend"; + if ((IntValues[179] & 256) == 256) retVal += " LightRend"; + if ((IntValues[179] & 512) == 512) retVal += " FireRend"; + if ((IntValues[179] & 1024) == 1024) retVal += " MeleeImbue"; + if ((IntValues[179] & 4096) == 4096) retVal += " MagicImbue"; + if ((IntValues[179] & 8192) == 8192) retVal += " Hematited"; + if ((IntValues[179] & 536870912) == 536870912) retVal += " MagicAbsorb"; + retVal = retVal.Trim(); + return retVal; + } + } + + public int Tinks { get { return IntValues.ContainsKey(171) ? IntValues[171] : -1; } } + + public int MaxDamage { get { return IntValues.ContainsKey(218103842) ? IntValues[218103842] : -1; } } + + public int ElementalDmgBonus { get { return IntValues.ContainsKey(204) ? IntValues[204] : -1; } } + + public double Variance { get { return DoubleValues.ContainsKey(167772171) ? DoubleValues[167772171] : -1; } } + + public double DamageBonus { get { return DoubleValues.ContainsKey(167772174) ? DoubleValues[167772174] : -1; } } + + public double ElementalDamageVersusMonsters { get { return DoubleValues.ContainsKey(152) ? DoubleValues[152] : -1; } } + + public double AttackBonus { get { return DoubleValues.ContainsKey(167772172) ? DoubleValues[167772172] : -1; } } + + public double MeleeDefenseBonus { get { return DoubleValues.ContainsKey(29) ? DoubleValues[29] : -1; } } + + public double MagicDBonus { get { return DoubleValues.ContainsKey(150) ? DoubleValues[150] : -1; } } + + public double MissileDBonus { get { return DoubleValues.ContainsKey(149) ? DoubleValues[149] : -1; } } + + public double ManaCBonus { get { return DoubleValues.ContainsKey(144) ? DoubleValues[144] : -1; } } + + public int WieldLevel { get { if (IntValues.ContainsKey(160) && IntValues[160] > 0 && IntValues.ContainsKey(158) && IntValues[158] == 7 && IntValues.ContainsKey(159) && IntValues[159] == 1) return IntValues[160]; return -1; } } + + public int SkillLevel { get { if (IntValues.ContainsKey(160) && IntValues[160] > 0 && (!IntValues.ContainsKey(158) || IntValues[158] != 7) && IntValues.ContainsKey(159)) return IntValues[160]; return -1; } } + + public int LoreRequirement { get { return IntValues.ContainsKey(109) ? IntValues[109] : -1; } } + + public double SalvageWorkmanship { get { return DoubleValues.ContainsKey(167772169) ? DoubleValues[167772169] : -1; } } + + public int Workmanship { get { return IntValues.ContainsKey(105) ? IntValues[105] : -1; } } + + public int Value { get { return IntValues.ContainsKey(19) ? IntValues[19] : -1; } } + + public int Burden { get { return IntValues.ContainsKey(5) ? IntValues[5] : -1; } } + + public int DamRating { get { return IntValues.ContainsKey(370) ? IntValues[370] : -1; } } + + public int DamResistRating { get { return IntValues.ContainsKey(371) ? IntValues[371] : -1; } } + + public int CritRating { get { return IntValues.ContainsKey(372) ? IntValues[372] : -1; } } + + public int CritResistRating { get { return IntValues.ContainsKey(373) ? IntValues[373] : -1; } } + + public int CritDamRating { get { return IntValues.ContainsKey(374) ? IntValues[374] : -1; } } + + public int CritDamResistRating { get { return IntValues.ContainsKey(375) ? IntValues[375] : -1; } } + + public int HealBoostRating { get { return IntValues.ContainsKey(376) ? IntValues[376] : -1; } } + + public int VitalityRating { get { return IntValues.ContainsKey(379) ? IntValues[379] : -1; } } + + /// The sum of every rating on the item, or -1 when it carries none. + public int TotalRating + { + get + { + if (DamRating == -1 && DamResistRating == -1 && CritRating == -1 && CritResistRating == -1 && CritDamRating == -1 && CritDamResistRating == -1 && HealBoostRating == -1 && VitalityRating == -1) + return -1; + + return Math.Max(DamRating, 0) + Math.Max(DamResistRating, 0) + Math.Max(CritRating, 0) + Math.Max(CritResistRating, 0) + Math.Max(CritDamRating, 0) + Math.Max(CritDamResistRating, 0) + Math.Max(HealBoostRating, 0) + Math.Max(VitalityRating, 0); + } + } + + /// + /// The armor level less buffs and tinks (20 each, the imbue excluded), plus + /// any impenetrability cantrip; a non-loot item (no material) loses 200. + /// + public int CalcedStartingArmorLevel + { + get + { + int armorFromTinks = 0; + int armorFromBuffs = 0; + + if (Tinks > 0 && ArmorLevel > 0) + armorFromTinks = (Imbue != null) ? (Tinks - 1) * 20 : Tinks * 20; + + if ((!IntValues.ContainsKey(131) || IntValues[131] == 0) && ArmorLevel > 0) + armorFromTinks = 200; + + foreach (int spell in ActiveSpells) + { + foreach (KeyValuePair> effect in ItemDictionaries.LongValueKeySpellEffects) + { + if (spell == effect.Key && effect.Value.Key == 28) + armorFromBuffs += effect.Value.Change; + } + } + + foreach (int spell in Spells) + { + foreach (KeyValuePair> effect in ItemDictionaries.LongValueKeySpellEffects) + { + if (spell == effect.Key && effect.Value.Key == 28) + armorFromBuffs -= effect.Value.Bonus; + } + } + + return ArmorLevel - armorFromTinks - armorFromBuffs; + } + } + + /// A melee weapon's best damage over time after ten tinks, from its variance, max damage and tinks. + public double CalcedBuffedTinkedDoT + { + get + { + if (!DoubleValues.ContainsKey(167772171) || !IntValues.ContainsKey(218103842)) + return -1; + + double variance = DoubleValues.ContainsKey(167772171) ? DoubleValues[167772171] : 0; + int maxDamage = GetBuffedIntValueKey(218103842); + + int numberOfTinksLeft = Math.Max(10 - Math.Max(Tinks, 0), 0); + + if (!IntValues.ContainsKey(179) || IntValues[179] == 0) + numberOfTinksLeft--; + + if (!IntValues.ContainsKey(131) || IntValues[131] == 0) + numberOfTinksLeft = 0; + + for (int i = 1; i <= numberOfTinksLeft; i++) + { + double ironTinkDoT = CalculateDamageOverTime(maxDamage + 24 + 1, variance); + double graniteTinkDoT = CalculateDamageOverTime(maxDamage + 24, variance * .8); + + if (ironTinkDoT >= graniteTinkDoT) + maxDamage++; + else + variance *= .8; + } + + return CalculateDamageOverTime(maxDamage + 24, variance); + } + } + + public double CalcedBuffedMissileDamage { get { if (!IntValues.ContainsKey(218103842) || !DoubleValues.ContainsKey(167772174) || !IntValues.ContainsKey(204)) return -1; return GetBuffedIntValueKey(218103842) + (((GetBuffedDoubleValueKey(167772174) - 1) * 100) / 3) + GetBuffedIntValueKey(204); } } + + public double BuffedElementalDamageVersusMonsters { get { return GetBuffedDoubleValueKey(152, -1); } } + + public double BuffedAttackBonus { get { return GetBuffedDoubleValueKey(167772172, -1); } } + + public double BuffedMeleeDefenseBonus { get { return GetBuffedDoubleValueKey(29, -1); } } + + public double BuffedManaCBonus { get { return GetBuffedDoubleValueKey(144, -1); } } + + public int GetBuffedIntValueKey(int key, int defaultValue = 0) + { + if (!IntValues.ContainsKey(key)) + return defaultValue; + + int value = IntValues[key]; + + foreach (int spell in ActiveSpells) + { + if (ItemDictionaries.LongValueKeySpellEffects.ContainsKey(spell) && ItemDictionaries.LongValueKeySpellEffects[spell].Key == key) + value -= ItemDictionaries.LongValueKeySpellEffects[spell].Change; + } + + foreach (int spell in Spells) + { + if (ItemDictionaries.LongValueKeySpellEffects.ContainsKey(spell) && ItemDictionaries.LongValueKeySpellEffects[spell].Key == key) + value += ItemDictionaries.LongValueKeySpellEffects[spell].Bonus; + } + + return value; + } + + public double GetBuffedDoubleValueKey(int key, double defaultValue = 0) + { + if (!DoubleValues.ContainsKey(key)) + return defaultValue; + + double value = DoubleValues[key]; + + foreach (int spell in ActiveSpells) + { + if (ItemDictionaries.DoubleValueKeySpellEffects.ContainsKey(spell) && ItemDictionaries.DoubleValueKeySpellEffects[spell].Key == key) + { + if (Math.Abs(ItemDictionaries.DoubleValueKeySpellEffects[spell].Change - 1) < double.Epsilon) + value /= ItemDictionaries.DoubleValueKeySpellEffects[spell].Change; + else + value -= ItemDictionaries.DoubleValueKeySpellEffects[spell].Change; + } + } + + foreach (int spell in Spells) + { + if (ItemDictionaries.DoubleValueKeySpellEffects.ContainsKey(spell) && ItemDictionaries.DoubleValueKeySpellEffects[spell].Key == key && Math.Abs(ItemDictionaries.DoubleValueKeySpellEffects[spell].Bonus - 0) > double.Epsilon) + { + if (Math.Abs(ItemDictionaries.DoubleValueKeySpellEffects[spell].Change - 1) < double.Epsilon) + value *= ItemDictionaries.DoubleValueKeySpellEffects[spell].Bonus; + else + value += ItemDictionaries.DoubleValueKeySpellEffects[spell].Bonus; + } + } + + return value; + } + + /// maxDamage * ((1 - critChance) * (2 - variance) / 2 + (critChance * critMultiplier)). + public static double CalculateDamageOverTime(int maxDamage, double variance, double critChance = .1, double critMultiplier = 2) + { + return maxDamage * ((1 - critChance) * (2 - variance) / 2 + (critChance * critMultiplier)); + } + + public override string? ToString() + { + return Name; + } +} diff --git a/tests/OpenAC.MosswartMassacre.Tests/Inventory/DecalItemBuilderTests.cs b/tests/OpenAC.MosswartMassacre.Tests/Inventory/DecalItemBuilderTests.cs new file mode 100644 index 0000000..ba9a2cc --- /dev/null +++ b/tests/OpenAC.MosswartMassacre.Tests/Inventory/DecalItemBuilderTests.cs @@ -0,0 +1,114 @@ +using AcDream.Plugin.Abstractions; +using OpenAC.MosswartMassacre.Backend; +using OpenAC.MosswartMassacre.Inventory; +using static OpenAC.MosswartMassacre.Tests.Inventory.InventoryWorld; + +namespace OpenAC.MosswartMassacre.Tests.Inventory; + +public sealed class DecalItemBuilderTests +{ + [Fact] + public void With_the_header_flags_known_a_field_sent_as_zero_is_present_and_an_unsent_one_absent() + { + // The original's dumps hold "218103822": 0 for items whose header + // carried a zero equipable-slots field (1153 of 3058 items). + PluginInventoryItem tapers = Tapers(); + uint flags = HeaderFlags.Value | HeaderFlags.Usable | HeaderFlags.ValidLocations + | HeaderFlags.StackSize | HeaderFlags.MaxStackSize | HeaderFlags.PluralName; + + MyWorldObject record = DecalItemBuilder.Create(new ItemSource( + tapers, null, false, 0, new ItemHeaderWords { WeenieHeaderFlags = flags })); + + Assert.Equal(0, record.IntValues[DecalKeys.EquipableSlots]); + Assert.Equal(unchecked((int)flags), record.IntValues[DecalKeys.CreateFlags1]); + Assert.False(record.IntValues.ContainsKey(DecalKeys.RawBurden)); + } + + [Fact] + public void Without_the_header_flags_a_zero_field_is_left_out_and_the_value_key_reads_minus_one() + { + PluginInventoryItem bare = FakeItemsBare(0x80000555u, "Bare Thing"); + + MyWorldObject record = DecalItemBuilder.Create(new ItemSource(bare, null, false, 0, default)); + + Assert.Equal(-1, record.IntValues[DecalKeys.RawValue]); + Assert.Equal(-1, record.Value); + Assert.False(record.IntValues.ContainsKey(DecalKeys.EquipableSlots)); + Assert.False(record.IntValues.ContainsKey(DecalKeys.CreateFlags1)); + Assert.False(record.IntValues.ContainsKey(DecalKeys.PhysicsDataFlags)); + Assert.False(record.IntValues.ContainsKey(DecalKeys.Setup)); + } + + [Fact] + public void The_physics_and_second_header_words_are_written_when_known() + { + PluginInventoryItem tachi = Tachi(appraised: false); + var words = new ItemHeaderWords + { + WeenieHeaderFlags2 = 1u, + PhysicsDescriptionFlags = 0x21881u, + PhysicsWord = 17u, + SetupId = 0x02000155u, + Scale = 0d, + ParentObjectId = Me, + ParentLocation = 1, + HookType = 2, + }; + + MyWorldObject record = DecalItemBuilder.Create(new ItemSource(tachi, null, false, 0, words)); + + Assert.Equal(1, record.IntValues[DecalKeys.CreateFlags2]); + Assert.Equal(0x21881, record.IntValues[DecalKeys.PhysicsDataFlags]); + Assert.Equal(17, record.IntValues[DecalKeys.PhysicsWord]); + Assert.Equal(0x02000155, record.IntValues[DecalKeys.Setup]); + Assert.Equal(0d, record.DoubleValues[DecalKeys.Scale]); + Assert.Equal(unchecked((int)Me), record.IntValues[DecalKeys.Wielder]); + Assert.Equal(1, record.IntValues[DecalKeys.WieldingSlot]); + Assert.Equal(2, record.IntValues[DecalKeys.HookType]); + } + + [Fact] + public void Combine_keeps_an_earlier_appraisal_and_refreshes_it_from_the_live_item() + { + // MyWorldObjectCreator.cs:44-61. + MyWorldObject older = DecalItemBuilder.Create(new ItemSource( + Tachi(appraised: true), TachiProps(), true, 1700000000, default)); + PluginInventoryItem moved = Tachi(appraised: false) with { WielderObjectId = 0u, ContainerObjectId = 0x80000900u }; + MyWorldObject newer = DecalItemBuilder.Create(new ItemSource(moved, null, false, 0, default)); + + MyWorldObject combined = DecalItemBuilder.Combine(older, newer); + + Assert.Same(older, combined); + Assert.True(combined.HasIdData); + Assert.Equal(1700000000, combined.LastIdTime); + Assert.Equal([2598, 2603], combined.Spells); + Assert.Equal(50, combined.MaxDamage); + Assert.Equal(unchecked((int)0x80000900u), combined.ContainerId); + Assert.Equal(unchecked((int)0x80000900u), combined.IntValues[DecalKeys.Container]); + Assert.Equal(combined.IntValues.Keys.Order(), combined.IntValues.Keys); + } + + [Fact] + public void Combine_takes_the_live_item_when_it_is_appraised_or_the_old_one_was_not() + { + MyWorldObject older = DecalItemBuilder.Create(new ItemSource(Tapers(), null, false, 0, default)); + MyWorldObject newer = DecalItemBuilder.Create(new ItemSource(Tapers(60), null, false, 0, default)); + + Assert.Same(newer, DecalItemBuilder.Combine(older, newer)); + } + + [Fact] + public void A_saved_record_reads_back_and_writes_the_same_frame() + { + MyWorldObject record = DecalItemBuilder.Create(new ItemSource( + Breastplate(), BreastplateProps(), true, 1790325015, default)); + string json = Wire.Serialize(record); + + MyWorldObject? back = Newtonsoft.Json.JsonConvert.DeserializeObject(json); + + Assert.Equal(json, Wire.Serialize(back)); + } + + private static PluginInventoryItem FakeItemsBare(uint id, string name) => + Fakes.FakeItems.Item(id, name) with { ObjectClass = PluginObjectClass.Misc, MaximumStackSize = 1 }; +} diff --git a/tests/OpenAC.MosswartMassacre.Tests/Inventory/EquipmentCantripStateTests.cs b/tests/OpenAC.MosswartMassacre.Tests/Inventory/EquipmentCantripStateTests.cs new file mode 100644 index 0000000..1ed51a2 --- /dev/null +++ b/tests/OpenAC.MosswartMassacre.Tests/Inventory/EquipmentCantripStateTests.cs @@ -0,0 +1,95 @@ +using AcDream.Plugin.Abstractions; +using Newtonsoft.Json.Linq; +using OpenAC.MosswartMassacre.Inventory; +using static OpenAC.MosswartMassacre.Tests.Inventory.InventoryWorld; + +namespace OpenAC.MosswartMassacre.Tests.Inventory; + +public sealed class EquipmentCantripStateTests +{ + private static PluginHarness Arrange(params PluginActiveEnchantment[] enchantments) => + new("Mossy", arrange: host => + { + Own(host, Tachi(appraised: true), TachiProps(), hostIdTime: 5000); + host.Automation.Spells.All = + [ + Spell(2598, family: 100, difficulty: 150), + Spell(2603, family: 101, difficulty: 150), + Spell(5183, family: 200, difficulty: 400), + Spell(6089, family: 100, difficulty: 400), + Spell(6094, family: 101, difficulty: 400), + ]; + host.Automation.Character.ActiveEnchantments = enchantments; + }); + + private static JToken OnlyItem(PluginHarness harness) => + JObject.Parse(harness.FramesOfTypeRaw("equipment_cantrip_state").Last())["items"]!.Single(); + + [Fact] + public void A_timed_buff_does_not_keep_an_item_spell_running_only_an_item_cast_one_does() + { + using var harness = Arrange( + new PluginActiveEnchantment(6089, 100, 8, double.PositiveInfinity), + new PluginActiveEnchantment(6094, 101, 8, 1800d)); + harness.Login(); + harness.Feature().SendSnapshot(); + + JToken item = OnlyItem(harness); + Assert.Equal("not_active", (string)item["state"]!); + Assert.Equal(1500, (int)item["current_mana"]!); + Assert.Equal(JTokenType.Null, item["mana_time_remaining_seconds"]!.Type); + } + + [Fact] + public void An_item_without_mana_is_not_active_and_one_without_spells_is_left_out() + { + using var harness = Arrange(); + PluginItemProperties empty = TachiProps(); + var ints = new Dictionary(empty.Ints) { [107] = 0 }; + Own(harness.Host, Tachi(appraised: true), empty with { Ints = ints }, hostIdTime: 5000); + harness.Login(); + harness.Feature().SendSnapshot(); + Assert.Equal("not_active", (string)OnlyItem(harness)["state"]!); + + Own(harness.Host, Tachi(appraised: false) with { AppraisedSpellIds = [] }, TachiProps(), hostIdTime: 5000); + harness.Feature().SendSnapshot(); + Assert.Empty(JObject.Parse(harness.FramesOfTypeRaw("equipment_cantrip_state").Last())["items"]!); + } + + [Fact] + public void Changes_within_a_quarter_second_send_one_frame() + { + using var harness = Arrange(); + harness.Login(); + harness.Tick(0.3); + harness.Frames.Clear(); + + Raise(harness.Host, 0x80000202u, PluginObjectChangeKind.Updated); + harness.Tick(0.1); + Raise(harness.Host, 0x80000202u, PluginObjectChangeKind.Updated); + harness.Tick(0.2); + Assert.Empty(harness.FramesOfTypeRaw("equipment_cantrip_state")); + harness.Tick(0.1); + + Assert.Single(harness.FramesOfTypeRaw("equipment_cantrip_state")); + } + + [Fact] + public void A_change_in_the_characters_enchantments_sends_a_frame() + { + using var harness = Arrange(); + harness.Login(); + harness.Tick(0.3); + harness.Frames.Clear(); + + harness.Tick(1.0); + Assert.Empty(harness.FramesOfTypeRaw("equipment_cantrip_state")); + + harness.Host.Automation.Character.ActiveEnchantments = + [new PluginActiveEnchantment(6089, 100, 8, double.PositiveInfinity)]; + harness.Tick(1.0); + harness.Tick(0.3); + + Assert.Single(harness.FramesOfTypeRaw("equipment_cantrip_state")); + } +} diff --git a/tests/OpenAC.MosswartMassacre.Tests/Inventory/IdentifyQueueTests.cs b/tests/OpenAC.MosswartMassacre.Tests/Inventory/IdentifyQueueTests.cs new file mode 100644 index 0000000..c09a4eb --- /dev/null +++ b/tests/OpenAC.MosswartMassacre.Tests/Inventory/IdentifyQueueTests.cs @@ -0,0 +1,88 @@ +using AcDream.Plugin.Abstractions; +using OpenAC.MosswartMassacre.Inventory; +using OpenAC.MosswartMassacre.Tests.Fakes; + +namespace OpenAC.MosswartMassacre.Tests.Inventory; + +public sealed class IdentifyQueueTests +{ + private static (IdentifyQueue Queue, FakeAutomation Automation, List<(uint, bool)> Finished) Create() + { + var automation = new FakeAutomation { IsAvailable = true }; + var queue = new IdentifyQueue(() => automation); + var finished = new List<(uint, bool)>(); + queue.Finished += (id, ok) => finished.Add((id, ok)); + return (queue, automation, finished); + } + + [Fact] + public void Requests_go_out_one_at_a_time_and_the_next_waits_for_the_answer() + { + var (queue, automation, finished) = Create(); + queue.Enqueue(1); + queue.Enqueue(2); + queue.Enqueue(1); + + queue.Pump(0.015); + queue.Pump(0.015); + Assert.Equal([1u], automation.Objects.IdentifyRequests); + + queue.OnIdentReceived(1); + queue.Pump(0.015); + Assert.Equal([1u, 2u], automation.Objects.IdentifyRequests); + Assert.Equal([(1u, true)], finished); + Assert.False(queue.IsIdle); + + queue.OnIdentReceived(2); + Assert.True(queue.IsIdle); + } + + [Fact] + public void A_busy_slot_is_asked_again_on_a_later_tick() + { + var (queue, automation, _) = Create(); + automation.Objects.IdentifyBusyForCalls[7] = 2; + queue.Enqueue(7); + + queue.Pump(0.015); + queue.Pump(0.015); + queue.Pump(0.015); + + Assert.Equal([7u, 7u, 7u], automation.Objects.IdentifyRequests); + Assert.Equal(1, queue.Count); + } + + [Fact] + public void The_host_answer_or_abandon_finishes_a_request() + { + var (queue, automation, finished) = Create(); + queue.Enqueue(3); + queue.Enqueue(4); + queue.Pump(0.015); + + automation.Loot.Appraisal = new PluginAppraisalState(5, 0u, 3u); + queue.Pump(0.015); + automation.Loot.Appraisal = new PluginAppraisalState(6, 0u, 3u, LastAbandonedObjectId: 4u); + queue.Pump(0.015); + + Assert.Equal([(3u, true), (4u, false)], finished); + } + + [Fact] + public void An_unanswered_request_is_given_up_after_the_timeout_and_a_refusal_at_once() + { + var (queue, automation, finished) = Create(); + automation.Objects.IdentifyResultOverrides[8] = PluginItemCommandStatus.InvalidItem; + queue.Enqueue(8); + queue.Enqueue(9); + + queue.Pump(0.015); + Assert.Equal([(8u, false)], finished); + + for (int i = 0; i < 700; i++) + queue.Pump(0.015); + + Assert.Equal([(8u, false), (9u, false)], finished); + Assert.True(queue.IsIdle); + } +} diff --git a/tests/OpenAC.MosswartMassacre.Tests/Inventory/InventoryFeatureTests.cs b/tests/OpenAC.MosswartMassacre.Tests/Inventory/InventoryFeatureTests.cs new file mode 100644 index 0000000..a5cabb6 --- /dev/null +++ b/tests/OpenAC.MosswartMassacre.Tests/Inventory/InventoryFeatureTests.cs @@ -0,0 +1,273 @@ +using AcDream.Plugin.Abstractions; +using Newtonsoft.Json.Linq; +using OpenAC.MosswartMassacre.Inventory; +using static OpenAC.MosswartMassacre.Tests.Inventory.InventoryWorld; + +namespace OpenAC.MosswartMassacre.Tests.Inventory; + +public sealed class InventoryFeatureTests +{ + private const string Prefix = "[Mosswart Massacre] "; + + [Fact] + public void First_login_appraises_what_needs_it_and_sends_only_when_the_answers_are_in() + { + using var harness = new PluginHarness("Mossy", arrange: host => + { + Own(host, Tachi(appraised: false)); + Own(host, Tapers()); + }); + LoginAndSettle(harness); + harness.Tick(0.1); + + Assert.Contains(Prefix + "Requesting id information for all armor/weapon inventory. This will take a few minutes...", harness.ChatLines); + Assert.Equal([0x80000202u], harness.Host.Automation.Objects.IdentifyRequests); + harness.Tick(0.1); + Assert.Empty(harness.FramesOfTypeRaw("full_inventory")); + + Own(harness.Host, Tachi(appraised: true), TachiProps(), hostIdTime: 5000); + Raise(harness.Host, 0x80000202u, PluginObjectChangeKind.IdentReceived); + harness.Tick(0.1); + + JObject frame = JObject.Parse(harness.FramesOfTypeRaw("full_inventory").Single()); + Assert.Equal(2, (int)frame["item_count"]!); + Assert.True((bool)frame["items"]![0]!["HasIdData"]!); + Assert.Contains(Prefix + "Requesting id information for all armor/weapon inventory completed. Log file written.", harness.ChatLines); + Assert.Contains(Prefix + "Inventory sent to MosswartOverlord", harness.ChatLines); + Assert.NotNull(harness.Host.Storage.ReadText(InventoryFeature.CacheKey)); + } + + [Fact] + public void A_later_login_sends_at_once_and_keeps_the_saved_appraisal_of_an_unappraised_item() + { + using var harness = new PluginHarness("Mossy", arrange: host => + Own(host, Tachi(appraised: true), TachiProps(), hostIdTime: 5000)); + LoginAndSettle(harness); + harness.Tick(0.1); + harness.Tick(0.1); + Assert.Single(harness.FramesOfTypeRaw("full_inventory")); + + // Relog: the session's appraisal is gone, the saved copy is not. + harness.Plugin.OnLogoff(); + Own(harness.Host, Tachi(appraised: false)); + harness.Frames.Clear(); + harness.Host.Automation.Objects.IdentifyRequests.Clear(); + LoginAndSettle(harness); + harness.Tick(0.1); + + JObject frame = JObject.Parse(harness.FramesOfTypeRaw("full_inventory").Single()); + JToken item = frame["items"]![0]!; + Assert.True((bool)item["HasIdData"]!); + Assert.Equal(50, (int)item["MaxDamage"]!); + Assert.Equal("Light Weapons", (string)item["EquipSkill"]!); + // Saved data present, so the original did not ask again from the dump + // (MossyInventory.cs:253); the login stream already asked once. + Assert.Equal([0x80000202u], harness.Host.Automation.Objects.IdentifyRequests); + } + + [Fact] + public void Sendinventory_sends_at_once_when_everything_is_appraised() + { + using var harness = new PluginHarness("Mossy", arrange: host => Own(host, Tapers())); + LoginAndSettle(harness); + harness.Tick(0.1); + harness.Tick(0.1); + harness.Frames.Clear(); + + harness.Context.Commands.Dispatch("sendinventory"); + + Assert.Single(harness.FramesOfTypeRaw("full_inventory")); + Assert.Contains(Prefix + "[INV] All 1 items already identified. Sending...", harness.ChatLines); + Assert.Contains(Prefix + "[INV] Inventory upload completed", harness.ChatLines); + } + + [Fact] + public void Sendinventory_waits_for_appraisals_and_refuses_when_streaming_is_off() + { + using var harness = new PluginHarness("Mossy", arrange: host => Own(host, Tapers())); + LoginAndSettle(harness); + harness.Tick(0.1); + harness.Tick(0.1); + harness.Frames.Clear(); + Own(harness.Host, Ring()); + + harness.Context.Commands.Dispatch("sendinventory"); + Assert.Contains(Prefix + "[INV] Scanning 2 items, requesting ID for 1. Waiting for identification...", harness.ChatLines); + harness.Tick(0.1); + Assert.Empty(harness.FramesOfTypeRaw("full_inventory")); + + Own(harness.Host, Ring(), Props(ints: new() { [107] = 10, [108] = 20 }), hostIdTime: 42); + Raise(harness.Host, 0x80000404u, PluginObjectChangeKind.IdentReceived); + harness.Tick(0.1); + Assert.Single(harness.FramesOfTypeRaw("full_inventory")); + Assert.Contains(Prefix + "[INV] All items identified. Inventory upload completed.", harness.ChatLines); + + harness.Context.Settings.Current.WebSocketEnabled = false; + harness.Context.Commands.Dispatch("sendinventory"); + Assert.Contains(Prefix + "[INV] WebSocket streaming is disabled", harness.ChatLines); + } + + [Fact] + public void The_login_stream_is_tracked_without_adds_and_items_after_it_are_added() + { + using var harness = new PluginHarness("Mossy"); + harness.Login(); + InventoryFeature feature = harness.Feature(); + feature.IsBackendReady = () => true; + + Own(harness.Host, Tapers()); + Raise(harness.Host, 0x80000101u, PluginObjectChangeKind.Created); + Assert.False(feature.IsSettled); + for (int i = 0; i < 40 && !feature.IsSettled; i++) + harness.Tick(0.1); + + Assert.Empty(harness.FramesOfTypeRaw("inventory_delta")); + Assert.Equal(1, feature.TrackedCount); + + Own(harness.Host, Breastplate(), BreastplateProps(), hostIdTime: 9); + Raise(harness.Host, 0x80000303u, PluginObjectChangeKind.Created); + Assert.Equal("add", (string)JObject.Parse(harness.FramesOfTypeRaw("inventory_delta").Single())["action"]!); + } + + [Fact] + public void Changes_are_held_until_the_jittered_flush_between_two_and_five_minutes() + { + using var harness = new PluginHarness("Mossy", arrange: host => Own(host, Tapers())); + InventoryFeature feature = LoginAndSettle(harness); + harness.Frames.Clear(); + + Own(harness.Host, Tapers(60)); + Raise(harness.Host, 0x80000101u, PluginObjectChangeKind.Updated); + Raise(harness.Host, 0x80000101u, PluginObjectChangeKind.Updated); + Assert.Equal(1, feature.DirtyCount); + + for (int i = 0; i < 1190; i++) + harness.Tick(0.1); + Assert.Empty(harness.FramesOfTypeRaw("inventory_delta")); + + for (int i = 0; i < 1820 && harness.FramesOfTypeRaw("inventory_delta").Count == 0; i++) + harness.Tick(0.1); + + JObject update = JObject.Parse(harness.FramesOfTypeRaw("inventory_delta").Single()); + Assert.Equal("update", (string)update["action"]!); + Assert.Equal(60, (int)update["item"]!["IntValues"]!["218103814"]!); + } + + [Fact] + public void The_flush_interval_is_two_minutes_plus_up_to_three() + { + using var harness = new PluginHarness("Mossy"); + InventoryFeature feature = harness.Feature(); + + int[] samples = Enumerable.Range(0, 2000).Select(_ => feature.NextFlushIntervalMs()).ToArray(); + + Assert.All(samples, ms => Assert.InRange(ms, 120000, 300000)); + Assert.True(samples.Max() - samples.Min() > 150000); + } + + [Fact] + public void An_item_moved_out_of_the_inventory_is_removed_and_one_in_a_side_pack_is_added() + { + const uint pack = 0x80000900u; + using var harness = new PluginHarness("Mossy", arrange: host => + { + Own(host, Tapers()); + host.Automation.Objects.Replace(new PluginWorldObject(pack, 1u, "Pack", PluginObjectClass.Container, 0x200u, Me, 0u)); + }); + LoginAndSettle(harness); + harness.Frames.Clear(); + + Own(harness.Host, Ring() with { ContainerObjectId = pack, WielderObjectId = 0u, EquippedLocation = 0u }); + Raise(harness.Host, 0x80000404u, PluginObjectChangeKind.Moved); + Own(harness.Host, Tapers() with { ContainerObjectId = 0x7000AAAAu }); + Raise(harness.Host, 0x80000101u, PluginObjectChangeKind.Moved); + + string[] actions = harness.FramesOfTypeRaw("inventory_delta") + .Select(f => (string)JObject.Parse(f)["action"]!).ToArray(); + Assert.Equal(["add", "remove"], actions); + } + + [Fact] + public void A_relog_starts_tracking_afresh() + { + using var harness = new PluginHarness("Mossy", arrange: host => Own(host, Tapers())); + InventoryFeature feature = LoginAndSettle(harness); + Own(harness.Host, Tapers(60)); + Raise(harness.Host, 0x80000101u, PluginObjectChangeKind.Updated); + Assert.Equal(1, feature.DirtyCount); + + harness.Plugin.OnLogoff(); + Assert.Equal(0, feature.DirtyCount); + Assert.Equal(0, feature.TrackedCount); + + harness.Host.Automation.Character.Name = "Horan"; + harness.Frames.Clear(); + LoginAndSettle(harness); + Raise(harness.Host, 0x80000101u, PluginObjectChangeKind.Updated); + + Assert.Empty(harness.FramesOfTypeRaw("inventory_delta")); + Assert.Equal(1, feature.DirtyCount); + } + + [Fact] + public void Logoff_sends_the_inventory_while_the_socket_is_still_up() + { + using var harness = new PluginHarness("Mossy", arrange: host => Own(host, Tapers())); + LoginAndSettle(harness); + harness.Tick(0.1); + harness.Tick(0.1); + harness.Frames.Clear(); + + harness.Plugin.OnLogoff(); + + Assert.Single(harness.FramesOfTypeRaw("full_inventory")); + } + + [Fact] + public void The_taper_count_follows_the_stacks_in_the_packs() + { + using var harness = new PluginHarness("Mossy", arrange: host => Own(host, Tapers())); + InventoryFeature feature = LoginAndSettle(harness); + Assert.Equal(57, feature.PrismaticTaperCount); + + Own(harness.Host, Tapers(12)); + Raise(harness.Host, 0x80000101u, PluginObjectChangeKind.Updated); + Assert.Equal(12, feature.PrismaticTaperCount); + + Forget(harness.Host, 0x80000101u); + harness.Host.Events.RaiseObjectChanged(new PluginObjectChange(0x80000101u, PluginObjectChangeKind.Released)); + Assert.Equal(0, feature.PrismaticTaperCount); + } + + [Fact] + public void Inventory_log_off_sends_no_login_inventory() + { + using var harness = new PluginHarness("Mossy", arrange: host => Own(host, Tapers())); + harness.Login(); + harness.Context.Settings.Current.InventoryLog = false; + InventoryFeature feature = harness.Feature(); + feature.IsBackendReady = () => true; + for (int i = 0; i < 40; i++) + harness.Tick(0.1); + + Assert.Empty(harness.FramesOfTypeRaw("full_inventory")); + } + + [Fact] + public void The_appraisal_time_is_the_wall_clock_when_the_appraisal_arrived() + { + using var harness = new PluginHarness("Mossy"); + InventoryFeature feature = LoginAndSettle(harness); + Own(harness.Host, Tachi(appraised: false)); + Raise(harness.Host, 0x80000202u, PluginObjectChangeKind.Created); + + Own(harness.Host, Tachi(appraised: true), TachiProps(), hostIdTime: 5000); + Raise(harness.Host, 0x80000202u, PluginObjectChangeKind.IdentReceived); + harness.Clock.Advance(TimeSpan.FromMinutes(3)); + harness.Frames.Clear(); + feature.FlushDirtyItems(); + + JObject update = JObject.Parse(harness.FramesOfTypeRaw("inventory_delta").Single()); + Assert.Equal(1790325015, (int)update["item"]!["LastIdTime"]!); + } +} diff --git a/tests/OpenAC.MosswartMassacre.Tests/Inventory/InventoryGoldenTests.cs b/tests/OpenAC.MosswartMassacre.Tests/Inventory/InventoryGoldenTests.cs new file mode 100644 index 0000000..263fffc --- /dev/null +++ b/tests/OpenAC.MosswartMassacre.Tests/Inventory/InventoryGoldenTests.cs @@ -0,0 +1,155 @@ +using AcDream.Plugin.Abstractions; +using OpenAC.MosswartMassacre.Inventory; +using static OpenAC.MosswartMassacre.Tests.Inventory.InventoryWorld; + +namespace OpenAC.MosswartMassacre.Tests.Inventory; + +/// +/// Byte-for-byte frames, derived by hand from the original plugin's code. +/// Shape sources (original plugin, head 48cf13b): +/// +/// Item record: Shared/MyWorldObject.cs:16-29 (fields, in order) and +/// :118-315 (getters, in order; doubles stay doubles so -1 is written -1.0). +/// Record contents: Shared/MyWorldObjectCreator.cs:20-39 — the framework's +/// key tables, spells, active spells, id, appraisal time, class, icon, container. +/// Which framework keys exist and what they hold: the original's own item +/// dumps (3058 items), see . +/// Envelopes: MosswartMassacre/WebSocket.cs:339-380 (full_inventory, +/// inventory_delta add/update and remove) and :411-423 (equipment_cantrip_state); +/// the per-item mana entry EquipmentCantripStateTracker.cs:222-229. +/// +/// +public sealed class InventoryGoldenTests +{ + // The harness clock, 2026-09-25 08:30:15.123 UTC, in Unix seconds: the + // original stamped an appraisal with the wall clock when it arrived. + private const string IdStamp = "1790325015"; + + // Getters from ElementalDmgBonus onward for an item with none of their keys + // except Value/Burden (MyWorldObject.cs:163-315). + private const string TaperRecord = + """{"HasIdData":false,"Id":-2147483391,"LastIdTime":0,"ObjectClass":12,"Icon":11658,"ContainerId":1342177281,"BoolValues":{},"DoubleValues":{},"IntValues":{"5":57,"19":570,"218103808":20631,"218103809":11658,"218103810":1342177281,"218103814":57,"218103815":100,"218103834":4096,"218103835":18,"218103843":1},"StringValues":{"1":"Prismatic Taper","184549376":"Prismatic Tapers"},"ActiveSpells":[],"Spells":[],"Material":null,"Name":"Prismatic Taper","EquipSkill":null,"Mastery":null,"ItemSet":null,"ArmorLevel":-1,"Imbue":null,"Tinks":-1,"MaxDamage":-1,"ElementalDmgBonus":-1,"Variance":-1.0,"DamageBonus":-1.0,"ElementalDamageVersusMonsters":-1.0,"AttackBonus":-1.0,"MeleeDefenseBonus":-1.0,"MagicDBonus":-1.0,"MissileDBonus":-1.0,"ManaCBonus":-1.0,"WieldLevel":-1,"SkillLevel":-1,"LoreRequirement":-1,"SalvageWorkmanship":-1.0,"Workmanship":-1,"Value":570,"Burden":57,"DamRating":-1,"DamResistRating":-1,"CritRating":-1,"CritResistRating":-1,"CritDamRating":-1,"CritDamResistRating":-1,"HealBoostRating":-1,"VitalityRating":-1,"TotalRating":-1,"CalcedStartingArmorLevel":-1,"CalcedBuffedTinkedDoT":-1.0,"CalcedBuffedMissileDamage":-1.0,"BuffedElementalDamageVersusMonsters":-1.0,"BuffedAttackBonus":-1.0,"BuffedMeleeDefenseBonus":-1.0,"BuffedManaCBonus":-1.0}"""; + + // Armor profile -> 167772160..166 (slash, pierce, bludgeon, acid, lightning, fire, + // cold), floats widened; spell book split on the 0x80000000 bit; ItemSet 5 from + // Dictionaries.AttributeSetInfo; CalcedStartingArmorLevel (MyWorldObject.cs:232-265): + // 600 - 200 (no material) - (240 active Incantation of Impenetrability - 20 Minor + // Impenetrability bonus) = 180. + private const string BreastplateRecord = + """{"HasIdData":true,"Id":-2147482877,"LastIdTime":""" + IdStamp + ""","ObjectClass":2,"Icon":4660,"ContainerId":1342177281,"BoolValues":{"69":true},"DoubleValues":{"167772160":1.2000000476837158,"167772161":1.0,"167772162":0.800000011920929,"167772163":0.20000000298023224,"167772164":1.5,"167772165":0.4000000059604645,"167772166":0.6000000238418579},"IntValues":{"5":800,"19":5000,"28":600,"265":5,"218103808":6044,"218103809":4660,"218103810":1342177281,"218103821":1024,"218103822":512,"218103824":1,"218103834":2,"218103835":18,"218103838":2,"218103843":1,"218103848":2},"StringValues":{"1":"Celdon Breastplate"},"ActiveSpells":[4407,1483],"Spells":[1486,2604],"Material":null,"Name":"Celdon Breastplate","EquipSkill":null,"Mastery":null,"ItemSet":"Noble Relic Set","ArmorLevel":600,"Imbue":null,"Tinks":-1,"MaxDamage":-1,"ElementalDmgBonus":-1,"Variance":-1.0,"DamageBonus":-1.0,"ElementalDamageVersusMonsters":-1.0,"AttackBonus":-1.0,"MeleeDefenseBonus":-1.0,"MagicDBonus":-1.0,"MissileDBonus":-1.0,"ManaCBonus":-1.0,"WieldLevel":-1,"SkillLevel":-1,"LoreRequirement":-1,"SalvageWorkmanship":-1.0,"Workmanship":-1,"Value":5000,"Burden":800,"DamRating":-1,"DamResistRating":-1,"CritRating":-1,"CritResistRating":-1,"CritDamRating":-1,"CritDamResistRating":-1,"HealBoostRating":-1,"VitalityRating":-1,"TotalRating":-1,"CalcedStartingArmorLevel":180,"CalcedBuffedTinkedDoT":-1.0,"CalcedBuffedMissileDamage":-1.0,"BuffedElementalDamageVersusMonsters":-1.0,"BuffedAttackBonus":-1.0,"BuffedMeleeDefenseBonus":-1.0,"BuffedManaCBonus":-1.0}"""; + + // Wielded: the container key and ContainerId are the wielder. The second header + // (public flags 0x04000000) brings the underlay key, here 0. + private const string TachiUnappraisedRecord = + """{"HasIdData":false,"Id":-2147483134,"LastIdTime":0,"ObjectClass":1,"Icon":13162,"ContainerId":1342177281,"BoolValues":{},"DoubleValues":{"167772169":8.0},"IntValues":{"5":270,"10":1048576,"19":12000,"131":64,"218103808":30310,"218103809":13162,"218103810":1342177281,"218103822":1048576,"218103823":1,"218103824":1,"218103826":16,"218103834":1,"218103835":67108882,"218103843":6291460,"218103849":6958,"218103850":0},"StringValues":{"1":"Fire Tachi"},"ActiveSpells":[],"Spells":[],"Material":"Steel","Name":"Fire Tachi","EquipSkill":null,"Mastery":null,"ItemSet":null,"ArmorLevel":-1,"Imbue":null,"Tinks":-1,"MaxDamage":-1,"ElementalDmgBonus":-1,"Variance":-1.0,"DamageBonus":-1.0,"ElementalDamageVersusMonsters":-1.0,"AttackBonus":-1.0,"MeleeDefenseBonus":-1.0,"MagicDBonus":-1.0,"MissileDBonus":-1.0,"ManaCBonus":-1.0,"WieldLevel":-1,"SkillLevel":-1,"LoreRequirement":-1,"SalvageWorkmanship":8.0,"Workmanship":-1,"Value":12000,"Burden":270,"DamRating":-1,"DamResistRating":-1,"CritRating":-1,"CritResistRating":-1,"CritDamRating":-1,"CritDamResistRating":-1,"HealBoostRating":-1,"VitalityRating":-1,"TotalRating":-1,"CalcedStartingArmorLevel":-1,"CalcedBuffedTinkedDoT":-1.0,"CalcedBuffedMissileDamage":-1.0,"BuffedElementalDamageVersusMonsters":-1.0,"BuffedAttackBonus":-1.0,"BuffedMeleeDefenseBonus":-1.0,"BuffedManaCBonus":-1.0}"""; + + // Weapon profile -> 218103839..842 and 167772171..174. Getters: EquipSkill 45 -> + // "Light Weapons", Mastery 2 -> "Sword", Imbue 1 -> "CS", SkillLevel (158 != 7) 370, + // TotalRating 2. CalcedBuffedTinkedDoT (MyWorldObject.cs:270-302): max damage + // 50 - 24 (active Incantation of Blood Drinker) + 2 (Minor Blood Thirst) = 28, seven + // tinks left, giving 55.431200000000004. BuffedAttackBonus: 1.2000000476837158 + .03 + // (Minor Heart Thirst) = 1.2300000476837158. + private const string TachiAppraisedRecord = + """{"HasIdData":true,"Id":-2147483134,"LastIdTime":""" + IdStamp + ""","ObjectClass":1,"Icon":13162,"ContainerId":1342177281,"BoolValues":{},"DoubleValues":{"5":-0.05,"29":1.15,"167772169":8.0,"167772171":0.5,"167772172":1.2000000476837158,"167772173":1.0,"167772174":1.0},"IntValues":{"5":270,"10":1048576,"19":12000,"105":8,"107":1500,"108":2000,"109":300,"131":64,"158":2,"159":45,"160":370,"171":3,"179":1,"353":2,"370":2,"218103808":30310,"218103809":13162,"218103810":1342177281,"218103822":1048576,"218103823":1,"218103824":1,"218103826":16,"218103834":1,"218103835":67108882,"218103838":2,"218103839":30,"218103840":45,"218103841":16,"218103842":50,"218103843":6291460,"218103848":1,"218103849":6958,"218103850":0},"StringValues":{"1":"Fire Tachi","16":"Fire Tachi of Blood Drinker"},"ActiveSpells":[5183],"Spells":[2598,2603],"Material":"Steel","Name":"Fire Tachi","EquipSkill":"Light Weapons","Mastery":"Sword","ItemSet":null,"ArmorLevel":-1,"Imbue":"CS","Tinks":3,"MaxDamage":50,"ElementalDmgBonus":-1,"Variance":0.5,"DamageBonus":1.0,"ElementalDamageVersusMonsters":-1.0,"AttackBonus":1.2000000476837158,"MeleeDefenseBonus":1.15,"MagicDBonus":-1.0,"MissileDBonus":-1.0,"ManaCBonus":-1.0,"WieldLevel":-1,"SkillLevel":370,"LoreRequirement":300,"SalvageWorkmanship":8.0,"Workmanship":8,"Value":12000,"Burden":270,"DamRating":2,"DamResistRating":-1,"CritRating":-1,"CritResistRating":-1,"CritDamRating":-1,"CritDamResistRating":-1,"HealBoostRating":-1,"VitalityRating":-1,"TotalRating":2,"CalcedStartingArmorLevel":-1,"CalcedBuffedTinkedDoT":55.431200000000004,"CalcedBuffedMissileDamage":-1.0,"BuffedElementalDamageVersusMonsters":-1.0,"BuffedAttackBonus":1.2300000476837158,"BuffedMeleeDefenseBonus":1.15,"BuffedManaCBonus":-1.0}"""; + + [Fact] + public void Full_inventory_frame_after_the_first_login() + { + using var harness = new PluginHarness("Mossy", arrange: host => + { + Own(host, Tapers()); + Own(host, Breastplate(), BreastplateProps(), hostIdTime: 777); + }); + + LoginAndSettle(harness); + harness.Tick(0.1); // the login upload: no saved copy, so appraise and wait + harness.Tick(0.1); // nothing needed appraising: send + + // WebSocket.cs:341-348: type, timestamp, character_name, item_count, items. + string expected = + """{"type":"full_inventory","timestamp":"2026-09-25T08:30:15.1230000Z","character_name":"Mossy","item_count":2,"items":[""" + + TaperRecord + "," + BreastplateRecord + "]}"; + Assert.Equal([expected], harness.FramesOfTypeRaw("full_inventory")); + } + + [Fact] + public void Inventory_delta_add_then_update_after_appraisal() + { + using var harness = new PluginHarness("Mossy"); + InventoryFeature feature = LoginAndSettle(harness); + harness.Tick(0.1); + harness.Tick(0.1); + harness.Frames.Clear(); + + Own(harness.Host, Tachi(appraised: false)); + Raise(harness.Host, 0x80000202u, PluginObjectChangeKind.Created); + + // WebSocket.cs:356-363: type, timestamp, character_name, action, item. + Assert.Equal( + ["""{"type":"inventory_delta","timestamp":"2026-09-25T08:30:15.1230000Z","character_name":"Mossy","action":"add","item":""" + TachiUnappraisedRecord + "}"], + harness.FramesOfTypeRaw("inventory_delta")); + + harness.Tick(0.015); + Assert.Contains(0x80000202u, harness.Host.Automation.Objects.IdentifyRequests); + + Own(harness.Host, Tachi(appraised: true), TachiProps(), hostIdTime: 5000); + Raise(harness.Host, 0x80000202u, PluginObjectChangeKind.IdentReceived); + harness.Frames.Clear(); + feature.FlushDirtyItems(); + + Assert.Equal( + ["""{"type":"inventory_delta","timestamp":"2026-09-25T08:30:15.1230000Z","character_name":"Mossy","action":"update","item":""" + TachiAppraisedRecord + "}"], + harness.FramesOfTypeRaw("inventory_delta")); + } + + [Fact] + public void Inventory_delta_remove_carries_only_the_id() + { + using var harness = new PluginHarness("Mossy", arrange: host => Own(host, Tapers())); + LoginAndSettle(harness); + harness.Frames.Clear(); + + Forget(harness.Host, 0x80000101u); + harness.Host.Events.RaiseObjectChanged(new PluginObjectChange(0x80000101u, PluginObjectChangeKind.Released)); + + // WebSocket.cs:370-377: the id is the framework's signed 32-bit id. + Assert.Equal( + ["""{"type":"inventory_delta","timestamp":"2026-09-25T08:30:15.1230000Z","character_name":"Mossy","action":"remove","item_id":-2147483391}"""], + harness.FramesOfTypeRaw("inventory_delta")); + } + + [Fact] + public void Equipment_cantrip_state_for_an_active_weapon_and_an_unappraised_ring() + { + using var harness = new PluginHarness("Mossy", arrange: host => + { + Own(host, Tachi(appraised: true), TachiProps(), hostIdTime: 5000); + Own(host, Ring()); + host.Automation.Spells.All = + [ + Spell(2598, family: 100, difficulty: 150), + Spell(2603, family: 101, difficulty: 150), + Spell(5183, family: 200, difficulty: 400), + Spell(6089, family: 100, difficulty: 400), + Spell(6094, family: 101, difficulty: 400), + ]; + host.Automation.Character.ActiveEnchantments = + [ + new PluginActiveEnchantment(6089, 100, 8, double.PositiveInfinity), + new PluginActiveEnchantment(6094, 101, 8, double.PositiveInfinity), + ]; + }); + harness.Login(); + harness.Frames.Clear(); + + // Mana was last reported at login; 65 s later three points have burned at + // one per ceil(-0.2 / -0.05) * 5 = 20 s (EquipmentCantripStateTracker.cs:296-345). + harness.Clock.Advance(TimeSpan.FromSeconds(65)); + harness.Feature().SendSnapshot(); + + // WebSocket.cs:413-419 envelope; items from EquipmentCantripStateTracker.cs:222-229. + Assert.Equal( + ["""{"type":"equipment_cantrip_state","timestamp":"2026-09-25T08:31:20.1230000Z","character_name":"Mossy","items":[{"item_id":-2147483134,"state":"active","current_mana":1497,"max_mana":2000,"mana_time_remaining_seconds":29940.0},{"item_id":-2147482620,"state":"unknown","current_mana":null,"max_mana":null,"mana_time_remaining_seconds":null}]}"""], + harness.FramesOfTypeRaw("equipment_cantrip_state")); + } +} diff --git a/tests/OpenAC.MosswartMassacre.Tests/Inventory/InventoryWorld.cs b/tests/OpenAC.MosswartMassacre.Tests/Inventory/InventoryWorld.cs new file mode 100644 index 0000000..8cf4265 --- /dev/null +++ b/tests/OpenAC.MosswartMassacre.Tests/Inventory/InventoryWorld.cs @@ -0,0 +1,205 @@ +using AcDream.Plugin.Abstractions; +using Newtonsoft.Json.Linq; +using OpenAC.MosswartMassacre.Inventory; +using OpenAC.MosswartMassacre.Tests.Fakes; + +namespace OpenAC.MosswartMassacre.Tests.Inventory; + +/// +/// Puts items into the fake host the way the real one holds them: the item +/// in the owned list, its world object in the object table, its property +/// tables when it has any. +/// +internal static class InventoryWorld +{ + /// The harness character's id. + public const uint Me = 0x50000001; + + public static PluginItemProperties Props( + Dictionary? ints = null, + Dictionary? floats = null, + Dictionary? bools = null, + Dictionary? strings = null, + PluginWeaponProfile? weapon = null, + PluginArmorProfile? armor = null) + => new( + ints ?? [], + new Dictionary(), + bools ?? [], + floats ?? [], + strings ?? [], + new Dictionary(), + new Dictionary()) + { + WeaponProfile = weapon, + ArmorProfile = armor, + }; + + /// + /// Adds or replaces an owned item. non-zero + /// with properties means appraised. + /// + public static void Own( + FakeHost host, + PluginInventoryItem item, + PluginItemProperties? properties = null, + int hostIdTime = 0) + { + List owned = host.Automation.Items.Owned; + int index = owned.FindIndex(existing => existing.ObjectId == item.ObjectId); + if (index >= 0) + owned[index] = item; + else + owned.Add(item); + + if (properties is { } props) + host.Automation.Items.Properties[item.ObjectId] = props; + else + host.Automation.Items.Properties.Remove(item.ObjectId); + + host.Automation.Objects.Replace(World(item, properties is not null, hostIdTime)); + } + + /// Takes an item out of the owned list and the object table. + public static void Forget(FakeHost host, uint objectId) + { + host.Automation.Items.Owned.RemoveAll(item => item.ObjectId == objectId); + host.Automation.Items.Properties.Remove(objectId); + host.Automation.Objects.Objects.RemoveAll(o => o.ObjectId == objectId); + } + + public static PluginWorldObject World(PluginInventoryItem item, bool hasProperties, int hostIdTime) + => new( + item.ObjectId, + item.WeenieClassId, + item.Name, + item.ObjectClass, + item.ItemType, + item.ContainerObjectId, + item.WielderObjectId) + { + IsOwned = true, + HasAppraisalData = hasProperties, + LastIdTime = hostIdTime, + SpellIds = item.AppraisedSpellIds, + IconId = item.IconId, + }; + + /// A change as the host raises it, with the object's current snapshot. + public static PluginObjectChange Change(FakeHost host, uint objectId, PluginObjectChangeKind kind) + => new(objectId, kind) + { + Current = host.Automation.Objects.TryGet(objectId, out PluginWorldObject current) + ? current + : null, + }; + + public static void Raise(FakeHost host, uint objectId, PluginObjectChangeKind kind) + => host.Events.RaiseObjectChanged(Change(host, objectId, kind)); + + /// Logs in and runs the ticks it takes the inventory to settle, without moving the clock. + public static InventoryFeature LoginAndSettle(PluginHarness harness) + { + harness.Login(); + InventoryFeature feature = harness.Feature(); + feature.IsBackendReady = () => true; + for (int i = 0; i < 40 && !feature.IsSettled; i++) + harness.Tick(0.1); + Assert.True(feature.IsSettled); + return feature; + } + + /// A stack of 57 Prismatic Tapers in the main pack, never appraised. + public static PluginInventoryItem Tapers(int stack = 57) => + new( + 0x80000101u, 20631u, "Prismatic Taper", 0x1000u, Me, 0u, 0u, 0u, + 1u, 0u, 18u, stack, 0, 0, 0u, 0, 0, 0u, false, 0d, 0, 0, 0, 0d, 0, 0, 0) + { + ObjectClass = PluginObjectClass.SpellComponent, + IconId = 0x06002D8Au, + MaximumStackSize = 100, + ContainerSlot = 0, + Burden = stack, + Value = stack * 10, + PluralName = "Prismatic Tapers", + }; + + /// A breastplate in the main pack, appraised, with two carried and two running spells. + public static PluginInventoryItem Breastplate() => + new( + 0x80000303u, 6044u, "Celdon Breastplate", 0x2u, Me, 0u, 512u, 0u, + 1u, 0u, 18u, 1, 0, 0, 0u, 0, 0, 0u, false, 0d, 0, 0, 0, 0d, 0, 0, 0) + { + ObjectClass = PluginObjectClass.Armor, + IconId = 0x06001234u, + ContainerSlot = 3, + Burden = 800, + Value = 5000, + CoverageMask = 1024u, + Effects = 1u, + AppraisedSpellIds = [1486u, 2604u, 0x80000000u | 4407u, 0x80000000u | 1483u], + }; + + public static PluginItemProperties BreastplateProps() => + Props( + ints: new() { [28] = 600, [265] = 5 }, + bools: new() { [69] = true }, + armor: new PluginArmorProfile(600, 1.2f, 1.0f, 0.8f, 0.6f, 0.4f, 0.2f, 0f, 1.5f)); + + /// A wielded sword, before (no spells) or after appraisal. + public static PluginInventoryItem Tachi(bool appraised) => + new( + 0x80000202u, 30310u, "Fire Tachi", 0x1u, 0u, Me, 1048576u, 1048576u, + 6291460u, 16u, 0x04000012u, 1, 0, 0, 0u, 0, 0, 0u, false, 0d, 0, 0, 0, 0d, 0, 0, 0) + { + ObjectClass = PluginObjectClass.MeleeWeapon, + IconId = 0x0600336Au, + IconOverlayId = 0x06001B2Eu, + IconUnderlayId = 0u, + ContainerSlot = -1, + CombatUse = 1, + Burden = 270, + Value = 12000, + MaterialType = 64u, + Workmanship = 8f, + Effects = 1u, + AppraisedSpellIds = appraised ? [2598u, 2603u, 0x80000000u | 5183u] : [], + }; + + public static PluginItemProperties TachiProps() => + Props( + ints: new() + { + [105] = 8, [107] = 1500, [108] = 2000, [109] = 300, [158] = 2, [159] = 45, + [160] = 370, [171] = 3, [179] = 1, [353] = 2, [370] = 2, + }, + floats: new() { [5] = -0.05, [29] = 1.15 }, + strings: new() { [16] = "Fire Tachi of Blood Drinker" }, + weapon: new PluginWeaponProfile(16, 30, 45u, 50, 0.5, 1.0, 1.5, 1.0, 1.2000000476837158, 0)); + + /// A ring the character wears, never appraised. + public static PluginInventoryItem Ring() => + new( + 0x80000404u, 2575u, "Gold Ring", 0x8u, 0u, Me, 0xC0000u, 0x40000u, + 1u, 0u, 18u, 1, 0, 0, 0u, 0, 0, 0u, false, 0d, 0, 0, 0, 0d, 0, 0, 0) + { + ObjectClass = PluginObjectClass.Jewelry, + IconId = 0x06000F6Eu, + ContainerSlot = -1, + Burden = 30, + Value = 400, + }; + + public static PluginSpellInfo Spell(uint id, uint family, int difficulty, bool debuff = false) + => new(id, $"Spell {id}", family, 1, difficulty, 10, -1f, 3u, string.Empty, false, true) + { + IsDebuff = debuff, + }; +} + +internal static class InventoryHarnessExtensions +{ + /// The raw frames of one type, in the order they were sent. + public static List FramesOfTypeRaw(this PluginHarness harness, string type) => + harness.Frames.Where(frame => (string?)JObject.Parse(frame)["type"] == type).ToList(); +}