Merge slice/inventory: full_inventory, inventory_delta, equipment_cantrip_state
Resolved FeatureCatalog; telemetry now reads the taper count from the inventory feature. The logoff inventory is sent from the feature's own OnLogoff instead of a second host-logoff subscription whose correctness depended on subscription order: the root logs features off in reverse, the socket last, so the frame still leaves. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
82a518dcfb
16 changed files with 3439 additions and 0 deletions
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
262
src/OpenAC.MosswartMassacre/Inventory/DecalItemBuilder.cs
Normal file
262
src/OpenAC.MosswartMassacre/Inventory/DecalItemBuilder.cs
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
using AcDream.Plugin.Abstractions;
|
||||
|
||||
namespace OpenAC.MosswartMassacre.Inventory;
|
||||
|
||||
/// <summary>
|
||||
/// Everything the builder reads for one item, gathered on the tick thread.
|
||||
/// </summary>
|
||||
/// <param name="Item">The item's header fields as the host reports them.</param>
|
||||
/// <param name="Properties">The item's raw property tables and appraisal profiles, when the host has them.</param>
|
||||
/// <param name="HasIdData">True once the item has been appraised this session.</param>
|
||||
/// <param name="LastIdTime">When it was appraised, in Unix seconds; zero when it has not been.</param>
|
||||
/// <param name="Words">The header words the contract does not expose yet.</param>
|
||||
internal readonly record struct ItemSource(
|
||||
PluginInventoryItem Item,
|
||||
PluginItemProperties? Properties,
|
||||
bool HasIdData,
|
||||
int LastIdTime,
|
||||
ItemHeaderWords Words);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class DecalItemBuilder
|
||||
{
|
||||
/// <summary>The item's own active enchantments carry this bit in the appraised spell book.</summary>
|
||||
public const uint ActiveSpellBit = 0x80000000u;
|
||||
|
||||
private const uint IconBase = 0x06000000u;
|
||||
|
||||
/// <summary>The original's <c>MyWorldObjectCreator.Create</c>.</summary>
|
||||
public static MyWorldObject Create(in ItemSource source)
|
||||
{
|
||||
PluginInventoryItem item = source.Item;
|
||||
ItemHeaderWords words = source.Words;
|
||||
var ints = new Dictionary<int, int>();
|
||||
var doubles = new Dictionary<int, double>();
|
||||
var bools = new Dictionary<int, bool>();
|
||||
var strings = new Dictionary<int, string>();
|
||||
|
||||
if (source.Properties is { } properties)
|
||||
{
|
||||
foreach (KeyValuePair<uint, int> kvp in properties.Ints ?? EmptyInts)
|
||||
ints[unchecked((int)kvp.Key)] = kvp.Value;
|
||||
foreach (KeyValuePair<uint, double> kvp in properties.Floats ?? EmptyDoubles)
|
||||
doubles[unchecked((int)kvp.Key)] = kvp.Value;
|
||||
foreach (KeyValuePair<uint, bool> kvp in properties.Bools ?? EmptyBools)
|
||||
bools[unchecked((int)kvp.Key)] = kvp.Value;
|
||||
foreach (KeyValuePair<uint, string> 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<int>();
|
||||
var activeSpells = new List<int>();
|
||||
IReadOnlyList<uint> spellBook = item.AppraisedSpellIds ?? Array.Empty<uint>();
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The original's <c>MyWorldObjectCreator.Combine</c>: 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An icon id as the framework reported it: without the icon table's
|
||||
/// 0x06000000 prefix.
|
||||
/// </summary>
|
||||
public static int ShortIcon(uint iconId) =>
|
||||
iconId >= IconBase ? unchecked((int)(iconId - IconBase)) : unchecked((int)iconId);
|
||||
|
||||
/// <summary>
|
||||
/// The framework's container of an item: the pack holding it, or the
|
||||
/// creature wielding it when it is wielded.
|
||||
/// </summary>
|
||||
public static int ContainerOf(in PluginInventoryItem item) =>
|
||||
unchecked((int)(item.ContainerObjectId != 0u ? item.ContainerObjectId : item.WielderObjectId));
|
||||
|
||||
private static Dictionary<int, T> Sorted<T>(Dictionary<int, T> table)
|
||||
{
|
||||
var sorted = new Dictionary<int, T>(table.Count);
|
||||
foreach (int key in table.Keys.Order())
|
||||
sorted[key] = table[key];
|
||||
return sorted;
|
||||
}
|
||||
|
||||
private static readonly IReadOnlyDictionary<uint, int> EmptyInts = new Dictionary<uint, int>();
|
||||
private static readonly IReadOnlyDictionary<uint, double> EmptyDoubles = new Dictionary<uint, double>();
|
||||
private static readonly IReadOnlyDictionary<uint, bool> EmptyBools = new Dictionary<uint, bool>();
|
||||
private static readonly IReadOnlyDictionary<uint, string> EmptyStrings = new Dictionary<uint, string>();
|
||||
}
|
||||
110
src/OpenAC.MosswartMassacre/Inventory/DecalKeys.cs
Normal file
110
src/OpenAC.MosswartMassacre/Inventory/DecalKeys.cs
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
namespace OpenAC.MosswartMassacre.Inventory;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>The public-flags bit that says a second header word follows.</summary>
|
||||
public const uint IncludesSecondHeader = 0x04000000;
|
||||
}
|
||||
|
|
@ -0,0 +1,425 @@
|
|||
using AcDream.Plugin.Abstractions;
|
||||
using OpenAC.MosswartMassacre.Backend;
|
||||
|
||||
namespace OpenAC.MosswartMassacre.Inventory;
|
||||
|
||||
/// <summary>
|
||||
/// The backend's mana panel: for every spell-bearing item the character
|
||||
/// wears or wields, whether its spells are running, its mana and how long
|
||||
/// that mana lasts, sent as <c>equipment_cantrip_state</c> a quarter second
|
||||
/// after the last relevant change.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// An item is <c>unknown</c> until appraised, <c>not_activatable</c> (and left
|
||||
/// out) without spells or mana, <c>not_active</c> when out of mana or when
|
||||
/// one of its beneficial spells is neither running on the item nor matched
|
||||
/// by an equal-or-stronger item spell on the character, and <c>active</c>
|
||||
/// otherwise. The mana of an active item is projected down from the moment
|
||||
/// its mana was last reported, at one point per burn interval.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The item is read through its item record, so every value is taken from
|
||||
/// the same keys the original read. The host raises no event when the
|
||||
/// character's enchantments change, so they are compared once a second.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class EquipmentCantripStateFeature : IMmFeature
|
||||
{
|
||||
public static readonly TimeSpan Debounce = TimeSpan.FromMilliseconds(250);
|
||||
public static readonly TimeSpan EnchantmentPoll = TimeSpan.FromSeconds(1);
|
||||
|
||||
private const int CloakSlot = 134217728;
|
||||
private const int AmmoSlot = 8388608;
|
||||
|
||||
private readonly MmContext _context;
|
||||
private readonly InventoryReader _reader;
|
||||
private readonly Action<PluginObjectChange> _onObjectChanged;
|
||||
private readonly Action<double> _onTick;
|
||||
private readonly Dictionary<uint, DateTime> _lastManaUpdateByItemId = [];
|
||||
private readonly Dictionary<uint, int> _lastSeenMana = [];
|
||||
private IDisposable? _debounce;
|
||||
private bool _active;
|
||||
private double _enchantmentPollSeconds;
|
||||
private string _enchantmentSignature = string.Empty;
|
||||
|
||||
public EquipmentCantripStateFeature(MmContext context, InventoryReader reader)
|
||||
{
|
||||
_context = context;
|
||||
_reader = reader;
|
||||
_onObjectChanged = OnObjectChanged;
|
||||
_onTick = OnTick;
|
||||
_context.Host.Events.ObjectChanged += _onObjectChanged;
|
||||
_context.Host.Events.Tick += _onTick;
|
||||
}
|
||||
|
||||
public void OnLogin()
|
||||
{
|
||||
Cleanup();
|
||||
_active = true;
|
||||
try
|
||||
{
|
||||
foreach (PluginInventoryItem item in _reader.CaptureOwned())
|
||||
RememberManaSnapshotTime(item.ObjectId);
|
||||
_enchantmentSignature = EnchantmentSignature();
|
||||
RequestRefresh();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_context.Host.Log.Warn($"[EquipCantrip] Initialize error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public void OnLogoff()
|
||||
{
|
||||
_active = false;
|
||||
Cleanup();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_context.Host.Events.ObjectChanged -= _onObjectChanged;
|
||||
_context.Host.Events.Tick -= _onTick;
|
||||
Cleanup();
|
||||
}
|
||||
|
||||
private void Cleanup()
|
||||
{
|
||||
_debounce?.Dispose();
|
||||
_debounce = null;
|
||||
_lastManaUpdateByItemId.Clear();
|
||||
_lastSeenMana.Clear();
|
||||
_enchantmentPollSeconds = 0d;
|
||||
_enchantmentSignature = string.Empty;
|
||||
}
|
||||
|
||||
private void OnTick(double deltaSeconds)
|
||||
{
|
||||
if (!_active)
|
||||
return;
|
||||
_enchantmentPollSeconds += deltaSeconds;
|
||||
if (_enchantmentPollSeconds < EnchantmentPoll.TotalSeconds)
|
||||
return;
|
||||
_enchantmentPollSeconds = 0d;
|
||||
string signature = EnchantmentSignature();
|
||||
if (signature != _enchantmentSignature)
|
||||
{
|
||||
_enchantmentSignature = signature;
|
||||
RequestRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
private string EnchantmentSignature() =>
|
||||
string.Join(',', _context.Automation.Character.ActiveEnchantments.Select(e => e.SpellId).Order());
|
||||
|
||||
private void OnObjectChanged(PluginObjectChange change)
|
||||
{
|
||||
if (!_active)
|
||||
return;
|
||||
try
|
||||
{
|
||||
switch (change.Kind)
|
||||
{
|
||||
case PluginObjectChangeKind.Created:
|
||||
if (change.Current is { } created && IsPotentiallyRelevant(created))
|
||||
{
|
||||
RememberManaSnapshotTime(created.ObjectId);
|
||||
RequestRefresh();
|
||||
}
|
||||
break;
|
||||
|
||||
case PluginObjectChangeKind.Released:
|
||||
bool wasTracked = _lastManaUpdateByItemId.Remove(change.ObjectId);
|
||||
_lastSeenMana.Remove(change.ObjectId);
|
||||
if (wasTracked || (change.Current is { } released && IsPotentiallyRelevant(released)))
|
||||
RequestRefresh();
|
||||
break;
|
||||
|
||||
default:
|
||||
if (change.Current is not { } changed)
|
||||
break;
|
||||
bool manaChanged = IsPotentiallyRelevant(changed) && ManaChanged(changed.ObjectId);
|
||||
if (change.Kind == PluginObjectChangeKind.IdentReceived || manaChanged)
|
||||
RememberManaSnapshotTime(changed.ObjectId);
|
||||
if (IsPotentiallyRelevant(changed))
|
||||
RequestRefresh();
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_context.Host.Log.Warn($"[EquipCantrip] Change error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The host has no mana-change event; a changed current-mana value is one.</summary>
|
||||
private bool ManaChanged(uint objectId)
|
||||
{
|
||||
int mana = CurrentMana(objectId);
|
||||
return _lastSeenMana.TryGetValue(objectId, out int previous) && previous != mana;
|
||||
}
|
||||
|
||||
private int CurrentMana(uint objectId) =>
|
||||
_context.Automation.Items.TryCaptureProperties(objectId, out PluginItemProperties properties)
|
||||
&& properties.Ints is { } ints
|
||||
&& ints.TryGetValue(DecalKeys.RawCurrentMana, out int mana)
|
||||
? mana
|
||||
: 0;
|
||||
|
||||
private void RememberManaSnapshotTime(uint objectId)
|
||||
{
|
||||
_lastManaUpdateByItemId[objectId] = _context.Clock.UtcNow;
|
||||
_lastSeenMana[objectId] = CurrentMana(objectId);
|
||||
}
|
||||
|
||||
private bool IsPotentiallyRelevant(PluginWorldObject wo) =>
|
||||
(wo.WielderObjectId != 0u && wo.WielderObjectId == _reader.CharacterId)
|
||||
|| _lastManaUpdateByItemId.ContainsKey(wo.ObjectId);
|
||||
|
||||
private void RequestRefresh()
|
||||
{
|
||||
_debounce?.Dispose();
|
||||
_debounce = _context.Scheduler.Delay(Debounce, SendSnapshot);
|
||||
}
|
||||
|
||||
internal void SendSnapshot()
|
||||
{
|
||||
_debounce = null;
|
||||
try
|
||||
{
|
||||
if (!_active || !_context.Settings.Current.WebSocketEnabled)
|
||||
return;
|
||||
|
||||
object[] items = BuildSnapshotItems().ToArray();
|
||||
_context.Send(new
|
||||
{
|
||||
type = "equipment_cantrip_state",
|
||||
timestamp = Wire.Timestamp(_context.Clock),
|
||||
character_name = _context.CharacterName,
|
||||
items = items,
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_context.Host.Log.Warn($"[EquipCantrip] Send error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private List<object> BuildSnapshotItems()
|
||||
{
|
||||
var snapshotItems = new List<object>();
|
||||
List<PluginSpellInfo> activeCharacterSpells = GetActiveCharacterItemSpellData();
|
||||
foreach (PluginInventoryItem item in _reader.CaptureOwned())
|
||||
{
|
||||
if (item.EquippedLocation == 0u)
|
||||
continue;
|
||||
MyWorldObject wo = _reader.Build(item);
|
||||
if (!ShouldDisplayInManaPanel(wo, item))
|
||||
continue;
|
||||
object? snapshot = BuildItemSnapshot(wo, activeCharacterSpells);
|
||||
if (snapshot is not null)
|
||||
snapshotItems.Add(snapshot);
|
||||
}
|
||||
return snapshotItems;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The character's item-cast enchantments, the ones with no end. The host
|
||||
/// reports those as never running out.
|
||||
/// </summary>
|
||||
private List<PluginSpellInfo> GetActiveCharacterItemSpellData()
|
||||
{
|
||||
var activeSpells = new List<PluginSpellInfo>();
|
||||
foreach (PluginActiveEnchantment enchantment in _context.Automation.Character.ActiveEnchantments)
|
||||
{
|
||||
if (!double.IsPositiveInfinity(enchantment.SecondsRemaining) && enchantment.SecondsRemaining > 0d)
|
||||
continue;
|
||||
if (_context.Automation.Spells.TryGet(enchantment.SpellId, out PluginSpellInfo spell))
|
||||
activeSpells.Add(spell);
|
||||
}
|
||||
return activeSpells;
|
||||
}
|
||||
|
||||
private object? BuildItemSnapshot(MyWorldObject wo, List<PluginSpellInfo> activeCharacterSpells)
|
||||
{
|
||||
ItemState itemState = RecalculateState(wo, activeCharacterSpells);
|
||||
if (itemState == ItemState.NotActivatable)
|
||||
return null;
|
||||
|
||||
int maximumMana = Int(wo, DecalKeys.RawMaximumMana, 0);
|
||||
int currentMana = itemState == ItemState.Unknown ? 0 : CalculateCurrentMana(wo, itemState);
|
||||
double? manaTimeRemainingSeconds = null;
|
||||
|
||||
if (itemState == ItemState.Active)
|
||||
{
|
||||
TimeSpan remaining = CalculateManaTimeRemaining(wo, currentMana);
|
||||
manaTimeRemainingSeconds = remaining.TotalSeconds;
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
item_id = wo.Id,
|
||||
state = ToWireState(itemState),
|
||||
current_mana = itemState == ItemState.Unknown ? (int?)null : currentMana,
|
||||
max_mana = itemState == ItemState.Unknown ? (int?)null : maximumMana,
|
||||
mana_time_remaining_seconds = manaTimeRemainingSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
private ItemState RecalculateState(MyWorldObject wo, List<PluginSpellInfo> activeCharacterSpells)
|
||||
{
|
||||
if (!wo.HasIdData)
|
||||
return ItemState.Unknown;
|
||||
|
||||
if (Int(wo, DecalKeys.SpellCount, 0) == 0 || Int(wo, DecalKeys.RawMaximumMana, 0) == 0)
|
||||
return ItemState.NotActivatable;
|
||||
|
||||
if (Int(wo, DecalKeys.RawCurrentMana, 0) == 0)
|
||||
return ItemState.NotActive;
|
||||
|
||||
foreach (int spellOnItemId in wo.Spells)
|
||||
{
|
||||
if (wo.IntValues.TryGetValue(DecalKeys.SpellDid, out int associated) && associated == spellOnItemId)
|
||||
continue;
|
||||
|
||||
if (!_context.Automation.Spells.TryGet(unchecked((uint)spellOnItemId), out PluginSpellInfo spellOnItem))
|
||||
continue;
|
||||
|
||||
if (spellOnItem.IsDebuff || spellOnItem.IsOffensive)
|
||||
continue;
|
||||
|
||||
bool thisSpellIsActive = false;
|
||||
foreach (int activeSpellId in wo.ActiveSpells)
|
||||
{
|
||||
if (_context.Automation.Spells.TryGet(unchecked((uint)activeSpellId), out PluginSpellInfo activeSpellOnItem)
|
||||
&& SpellMatchesOrSurpasses(activeSpellOnItem, spellOnItem))
|
||||
{
|
||||
thisSpellIsActive = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (thisSpellIsActive)
|
||||
continue;
|
||||
|
||||
foreach (PluginSpellInfo activeSpellOnChar in activeCharacterSpells)
|
||||
{
|
||||
if (SpellMatchesOrSurpasses(activeSpellOnChar, spellOnItem))
|
||||
{
|
||||
thisSpellIsActive = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!thisSpellIsActive)
|
||||
return ItemState.NotActive;
|
||||
}
|
||||
|
||||
return ItemState.Active;
|
||||
}
|
||||
|
||||
private static bool SpellMatchesOrSurpasses(PluginSpellInfo activeSpell, PluginSpellInfo requiredSpell) =>
|
||||
activeSpell.Family == requiredSpell.Family && activeSpell.Difficulty >= requiredSpell.Difficulty;
|
||||
|
||||
private int CalculateCurrentMana(MyWorldObject wo, ItemState itemState)
|
||||
{
|
||||
if (itemState == ItemState.Unknown || itemState == ItemState.NotActivatable)
|
||||
return 0;
|
||||
|
||||
if (itemState == ItemState.NotActive)
|
||||
return Int(wo, DecalKeys.RawCurrentMana, 0);
|
||||
|
||||
double secondsPerBurn = GetSecondsPerBurn(wo);
|
||||
if (secondsPerBurn <= 0)
|
||||
return Int(wo, DecalKeys.RawCurrentMana, 0);
|
||||
|
||||
uint objectId = unchecked((uint)wo.Id);
|
||||
DateTime now = _context.Clock.UtcNow;
|
||||
if (!_lastManaUpdateByItemId.TryGetValue(objectId, out DateTime lastUpdate))
|
||||
{
|
||||
lastUpdate = now;
|
||||
_lastManaUpdateByItemId[objectId] = lastUpdate;
|
||||
}
|
||||
|
||||
TimeSpan timeSinceLastUpdate = now - lastUpdate;
|
||||
int burnedMana = (int)(timeSinceLastUpdate.TotalSeconds / secondsPerBurn);
|
||||
int currentMana = Int(wo, DecalKeys.RawCurrentMana, 0);
|
||||
if (burnedMana > currentMana)
|
||||
burnedMana = currentMana;
|
||||
|
||||
return Math.Max(currentMana - burnedMana, 0);
|
||||
}
|
||||
|
||||
private static TimeSpan CalculateManaTimeRemaining(MyWorldObject wo, int calculatedCurrentMana)
|
||||
{
|
||||
double secondsPerBurn = GetSecondsPerBurn(wo);
|
||||
if (secondsPerBurn <= 0)
|
||||
return new TimeSpan(99, 99, 0);
|
||||
|
||||
return TimeSpan.FromSeconds(calculatedCurrentMana * secondsPerBurn);
|
||||
}
|
||||
|
||||
private static double GetSecondsPerBurn(MyWorldObject wo)
|
||||
{
|
||||
if (!wo.HasIdData)
|
||||
return 0;
|
||||
|
||||
double manaRateOfChange = wo.DoubleValues.TryGetValue(DecalKeys.RawManaRate, out double rate) ? rate : 0;
|
||||
if (manaRateOfChange == 0)
|
||||
return 0;
|
||||
|
||||
return ((int)Math.Ceiling(-0.2 / manaRateOfChange)) * 5;
|
||||
}
|
||||
|
||||
private bool ShouldDisplayInManaPanel(MyWorldObject wo, in PluginInventoryItem item)
|
||||
{
|
||||
if (!ItemIsEquippedByMe(wo, item))
|
||||
return false;
|
||||
|
||||
if (!string.IsNullOrEmpty(wo.Name) && wo.Name.Contains("Aetheria", StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
|
||||
if (Int(wo, DecalKeys.EquipableSlots, 0) == CloakSlot)
|
||||
return false;
|
||||
|
||||
if (Int(wo, DecalKeys.RawEquippedSlots, 0) == AmmoSlot)
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Worn or wielded by this character. The framework's slot key read -1
|
||||
/// for anything not in a pack slot, and then the container decided.
|
||||
/// </summary>
|
||||
private bool ItemIsEquippedByMe(MyWorldObject wo, in PluginInventoryItem item)
|
||||
{
|
||||
int equippedSlots = Int(wo, DecalKeys.RawEquippedSlots, 0);
|
||||
if (equippedSlots <= 0)
|
||||
return false;
|
||||
|
||||
if (item.ContainerSlot == -1)
|
||||
return wo.ContainerId == unchecked((int)_reader.CharacterId);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static int Int(MyWorldObject wo, int key, int defaultValue) =>
|
||||
wo.IntValues.TryGetValue(key, out int value) ? value : defaultValue;
|
||||
|
||||
private static string ToWireState(ItemState itemState) => itemState switch
|
||||
{
|
||||
ItemState.Active => "active",
|
||||
ItemState.NotActive => "not_active",
|
||||
ItemState.NotActivatable => "not_activatable",
|
||||
_ => "unknown",
|
||||
};
|
||||
|
||||
private enum ItemState
|
||||
{
|
||||
Unknown,
|
||||
NotActivatable,
|
||||
Active,
|
||||
NotActive,
|
||||
}
|
||||
}
|
||||
123
src/OpenAC.MosswartMassacre/Inventory/IdentifyQueue.cs
Normal file
123
src/OpenAC.MosswartMassacre/Inventory/IdentifyQueue.cs
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
using AcDream.Plugin.Abstractions;
|
||||
|
||||
namespace OpenAC.MosswartMassacre.Inventory;
|
||||
|
||||
/// <summary>
|
||||
/// Appraises the player's items one at a time. The original fired an
|
||||
/// appraisal request for every item at once and its framework queued them;
|
||||
/// the host here has a single appraisal slot and answers
|
||||
/// <see cref="PluginItemCommandStatus.Busy"/> while it is taken, so the queue
|
||||
/// asks again on a later tick instead.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A request is finished when the host reports it answered, gave up on it,
|
||||
/// or when it has been outstanding for <see cref="Timeout"/>. The owner
|
||||
/// pumps the queue from the tick; nothing here runs on another thread.
|
||||
/// </remarks>
|
||||
internal sealed class IdentifyQueue
|
||||
{
|
||||
/// <summary>How long one request may stay unanswered before it is given up.</summary>
|
||||
public static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
private readonly Func<IAutomationSurface> _automation;
|
||||
private readonly LinkedList<uint> _pending = new();
|
||||
private readonly HashSet<uint> _queued = [];
|
||||
private uint _inFlight;
|
||||
private double _inFlightSeconds;
|
||||
private long _appraisalRevisionAtRequest;
|
||||
|
||||
public IdentifyQueue(Func<IAutomationSurface> automation)
|
||||
{
|
||||
_automation = automation;
|
||||
}
|
||||
|
||||
/// <summary>Raised with the item id and whether the appraisal arrived, once per finished request.</summary>
|
||||
public event Action<uint, bool>? Finished;
|
||||
|
||||
/// <summary>True when nothing is waiting and nothing is outstanding.</summary>
|
||||
public bool IsIdle => _inFlight == 0u && _pending.Count == 0;
|
||||
|
||||
/// <summary>Requests queued or outstanding.</summary>
|
||||
public int Count => _pending.Count + (_inFlight != 0u ? 1 : 0);
|
||||
|
||||
/// <summary>Queues one item unless it is already queued or outstanding.</summary>
|
||||
public void Enqueue(uint objectId)
|
||||
{
|
||||
if (objectId == 0u || objectId == _inFlight || !_queued.Add(objectId))
|
||||
return;
|
||||
_pending.AddLast(objectId);
|
||||
}
|
||||
|
||||
/// <summary>Drops everything, for a logoff.</summary>
|
||||
public void Clear()
|
||||
{
|
||||
_pending.Clear();
|
||||
_queued.Clear();
|
||||
_inFlight = 0u;
|
||||
_inFlightSeconds = 0d;
|
||||
}
|
||||
|
||||
/// <summary>The host delivered appraisal data for an object.</summary>
|
||||
public void OnIdentReceived(uint objectId)
|
||||
{
|
||||
if (objectId != 0u && objectId == _inFlight)
|
||||
Finish(success: true);
|
||||
}
|
||||
|
||||
/// <summary>Advances the queue; call once per tick.</summary>
|
||||
public void Pump(double deltaSeconds)
|
||||
{
|
||||
if (_inFlight != 0u)
|
||||
{
|
||||
_inFlightSeconds += Math.Max(0d, deltaSeconds);
|
||||
PluginAppraisalState state = _automation().Loot.Appraisal;
|
||||
if (state.Revision != _appraisalRevisionAtRequest)
|
||||
{
|
||||
if (state.CurrentObjectId == _inFlight)
|
||||
{
|
||||
Finish(success: true);
|
||||
}
|
||||
else if (state.LastAbandonedObjectId == _inFlight)
|
||||
{
|
||||
Finish(success: false);
|
||||
}
|
||||
}
|
||||
if (_inFlight != 0u && _inFlightSeconds >= Timeout.TotalSeconds)
|
||||
Finish(success: false);
|
||||
if (_inFlight != 0u)
|
||||
return;
|
||||
}
|
||||
|
||||
while (_pending.First is { } next)
|
||||
{
|
||||
uint objectId = next.Value;
|
||||
IAutomationSurface automation = _automation();
|
||||
PluginItemCommandResult result = automation.Objects.Identify(objectId);
|
||||
if (result.Status == PluginItemCommandStatus.Busy)
|
||||
return;
|
||||
|
||||
_pending.RemoveFirst();
|
||||
_queued.Remove(objectId);
|
||||
if (result.Status == PluginItemCommandStatus.Started)
|
||||
{
|
||||
_inFlight = objectId;
|
||||
_inFlightSeconds = 0d;
|
||||
// Read after the request, so the request's own change to the
|
||||
// slot is not taken for its answer.
|
||||
_appraisalRevisionAtRequest = automation.Loot.Appraisal.Revision;
|
||||
return;
|
||||
}
|
||||
|
||||
// Refused outright: the item is gone or cannot be appraised.
|
||||
Finished?.Invoke(objectId, false);
|
||||
}
|
||||
}
|
||||
|
||||
private void Finish(bool success)
|
||||
{
|
||||
uint objectId = _inFlight;
|
||||
_inFlight = 0u;
|
||||
_inFlightSeconds = 0d;
|
||||
Finished?.Invoke(objectId, success);
|
||||
}
|
||||
}
|
||||
603
src/OpenAC.MosswartMassacre/Inventory/InventoryFeature.cs
Normal file
603
src/OpenAC.MosswartMassacre/Inventory/InventoryFeature.cs
Normal file
|
|
@ -0,0 +1,603 @@
|
|||
using AcDream.Plugin.Abstractions;
|
||||
using Newtonsoft.Json;
|
||||
using OpenAC.MosswartMassacre.Backend;
|
||||
|
||||
namespace OpenAC.MosswartMassacre.Inventory;
|
||||
|
||||
/// <summary>
|
||||
/// The player's inventory on the backend: the whole inventory
|
||||
/// (<c>full_inventory</c>) after login, at logoff and on
|
||||
/// <c>/mm sendinventory</c>; every item entering, changing in or leaving it
|
||||
/// (<c>inventory_delta</c>); and the Prismatic Taper count the telemetry
|
||||
/// frame carries.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Adds and removes go out at once. Changes to an item already tracked are
|
||||
/// coalesced and sent as one <c>update</c> per changed item on a randomized
|
||||
/// two-to-five-minute timer, re-rolled after every flush so a fleet of
|
||||
/// clients never flushes in step.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Login waits until the inventory has stopped arriving (no new owned item
|
||||
/// for <see cref="SettleQuiet"/>) and, while streaming, for the socket, so
|
||||
/// the login inventory is not written into a closed socket. Until then new
|
||||
/// items are tracked silently, as the original's login stream arrived before
|
||||
/// its socket was open. Items worth appraising are appraised one at a time
|
||||
/// through <see cref="IdentifyQueue"/>; the original asked for all of them at
|
||||
/// once.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A copy of the last inventory sent is kept in the character's storage, as
|
||||
/// the original kept a file per character. It decides whether a login first
|
||||
/// appraises everything and waits, and it keeps appraisal data from an
|
||||
/// earlier session for items not yet appraised in this one.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class InventoryFeature : IMmFeature
|
||||
{
|
||||
public const string CacheKey = "inventory.json";
|
||||
|
||||
/// <summary>How long no new owned item must arrive before login counts the inventory as complete.</summary>
|
||||
public static readonly TimeSpan SettleQuiet = TimeSpan.FromSeconds(3);
|
||||
|
||||
/// <summary>The longest login waits for the inventory to stop arriving.</summary>
|
||||
public static readonly TimeSpan SettleLimit = TimeSpan.FromSeconds(20);
|
||||
|
||||
/// <summary>The longest the login inventory waits for the socket before it is written anyway.</summary>
|
||||
public static readonly TimeSpan ConnectLimit = TimeSpan.FromSeconds(30);
|
||||
|
||||
public const int FlushBaseMs = 120000;
|
||||
public const int FlushJitterMs = 180000;
|
||||
|
||||
private readonly MmContext _context;
|
||||
private readonly InventoryReader _reader;
|
||||
private readonly IdentifyQueue _identify;
|
||||
private readonly Random _rng;
|
||||
private readonly Action<PluginObjectChange> _onObjectChanged;
|
||||
private readonly Action<double> _onTick;
|
||||
|
||||
// Live tracking (the original's live inventory tracker).
|
||||
private readonly HashSet<uint> _tracked = [];
|
||||
private readonly HashSet<uint> _dirty = [];
|
||||
private IDisposable? _flushTimer;
|
||||
|
||||
// Whole-inventory uploads (the original's inventory logger).
|
||||
private readonly HashSet<uint> _requested = [];
|
||||
private bool _waitingForLoginIds;
|
||||
private bool _waitingForForcedIds;
|
||||
|
||||
private bool _active;
|
||||
private bool _settled;
|
||||
private bool _loginUploadPending;
|
||||
private double _quietSeconds;
|
||||
private double _settleSeconds;
|
||||
private double _connectSeconds;
|
||||
|
||||
private bool _taperDirty = true;
|
||||
private int _taperCount;
|
||||
|
||||
public InventoryFeature(MmContext context, InventoryReader reader, IdentifyQueue identify, Random? rng = null)
|
||||
{
|
||||
_context = context;
|
||||
_reader = reader;
|
||||
_identify = identify;
|
||||
_rng = rng ?? new Random();
|
||||
IsBackendReady = () => context.Backend.IsConnected;
|
||||
_onObjectChanged = OnObjectChanged;
|
||||
_onTick = OnTick;
|
||||
_context.Host.Events.ObjectChanged += _onObjectChanged;
|
||||
_context.Host.Events.Tick += _onTick;
|
||||
|
||||
context.Commands.Register("sendinventory", _ => ForceInventoryUpload(), "Force full inventory upload");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The Prismatic Tapers in the packs, for the telemetry frame's
|
||||
/// <c>prismatic_taper_count</c>. Read it on the tick thread; it recounts
|
||||
/// only after an item changed.
|
||||
/// </summary>
|
||||
public int PrismaticTaperCount
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_active && _taperDirty)
|
||||
{
|
||||
_taperCount = _reader.CountPrismaticTapers();
|
||||
_taperDirty = false;
|
||||
}
|
||||
return _taperCount;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>True when the backend can take the login inventory. Tests replace it.</summary>
|
||||
internal Func<bool> IsBackendReady { get; set; }
|
||||
|
||||
internal bool IsSettled => _settled;
|
||||
|
||||
internal int TrackedCount => _tracked.Count;
|
||||
|
||||
internal int DirtyCount => _dirty.Count;
|
||||
|
||||
public void OnLogin()
|
||||
{
|
||||
ResetState();
|
||||
_active = true;
|
||||
_reader.Reset();
|
||||
_taperDirty = true;
|
||||
|
||||
// The original's item events were wired before login, so every item
|
||||
// of the login stream that was worth appraising was asked for.
|
||||
foreach (PluginInventoryItem item in _reader.CaptureOwned())
|
||||
{
|
||||
_tracked.Add(item.ObjectId);
|
||||
if (InventoryReader.NeedsIdent(item.ObjectClass, item.Name) && !_reader.IsIdentified(item.ObjectId))
|
||||
_identify.Enqueue(item.ObjectId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The original dumped the inventory at logoff. The root logs features
|
||||
/// off in reverse order, the socket last, so this frame still goes out.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>The inventory has arrived: start live tracking and the login upload.</summary>
|
||||
private void Settle()
|
||||
{
|
||||
_settled = true;
|
||||
foreach (PluginInventoryItem item in _reader.CaptureOwned())
|
||||
_tracked.Add(item.ObjectId);
|
||||
ArmFlushTimer();
|
||||
_loginUploadPending = true;
|
||||
_connectSeconds = 0d;
|
||||
}
|
||||
|
||||
/// <summary>The original's inventory logger at login.</summary>
|
||||
private void RunLoginUpload()
|
||||
{
|
||||
if (!InventoryLog)
|
||||
return;
|
||||
|
||||
if (ReadCache() is null)
|
||||
{
|
||||
_context.Chat.Write("Requesting id information for all armor/weapon inventory. This will take a few minutes...");
|
||||
foreach (PluginInventoryItem item in _reader.CaptureOwned())
|
||||
{
|
||||
if (!_reader.IsIdentified(item.ObjectId) && InventoryReader.NeedsIdent(item.ObjectClass, item.Name))
|
||||
_identify.Enqueue(item.ObjectId);
|
||||
}
|
||||
_waitingForLoginIds = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
DumpInventory(requestIdsIfMissing: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every appraisal asked for has finished. The original waited until
|
||||
/// every item worth appraising had been appraised, so one item the server
|
||||
/// would not appraise held the upload back for good; this sends once the
|
||||
/// requests have all been answered or given up.
|
||||
/// </summary>
|
||||
private void CompleteWaitingUpload()
|
||||
{
|
||||
bool wasForceUpload = _waitingForForcedIds;
|
||||
_waitingForLoginIds = false;
|
||||
_waitingForForcedIds = false;
|
||||
DumpInventory();
|
||||
if (wasForceUpload)
|
||||
_context.Chat.Write("[INV] All items identified. Inventory upload completed.");
|
||||
else
|
||||
_context.Chat.Write("Requesting id information for all armor/weapon inventory completed. Log file written.");
|
||||
}
|
||||
|
||||
/// <summary><c>/mm sendinventory</c>: appraise what needs it, wait, then send everything.</summary>
|
||||
internal void ForceInventoryUpload()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!InventoryLog)
|
||||
{
|
||||
_context.Chat.Write("[INV] Inventory logging is disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_context.Settings.Current.WebSocketEnabled)
|
||||
{
|
||||
_context.Chat.Write("[INV] WebSocket streaming is disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_active)
|
||||
{
|
||||
_context.Chat.Write("[INV] Inventory system not initialized");
|
||||
return;
|
||||
}
|
||||
|
||||
int needsId = 0;
|
||||
int totalItems = 0;
|
||||
foreach (PluginInventoryItem item in _reader.CaptureOwned())
|
||||
{
|
||||
totalItems++;
|
||||
if (!_reader.IsIdentified(item.ObjectId) && InventoryReader.NeedsIdent(item.ObjectClass, item.Name))
|
||||
{
|
||||
_identify.Enqueue(item.ObjectId);
|
||||
needsId++;
|
||||
}
|
||||
}
|
||||
|
||||
if (needsId > 0)
|
||||
{
|
||||
_waitingForForcedIds = true;
|
||||
_context.Chat.Write($"[INV] Scanning {totalItems} items, requesting ID for {needsId}. Waiting for identification...");
|
||||
}
|
||||
else
|
||||
{
|
||||
_context.Chat.Write($"[INV] All {totalItems} items already identified. Sending...");
|
||||
DumpInventory();
|
||||
_context.Chat.Write("[INV] Inventory upload completed");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_context.Chat.Write($"[INV] Force upload failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void OnHostLogoff()
|
||||
{
|
||||
if (!_active || !InventoryLog)
|
||||
return;
|
||||
try
|
||||
{
|
||||
DumpInventory();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_context.Chat.Write($"[INV] {ex}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The original's inventory dump: every owned item, merged with what the
|
||||
/// saved copy knew about it, saved, and sent as <c>full_inventory</c>.
|
||||
/// </summary>
|
||||
internal void DumpInventory(bool requestIdsIfMissing = false)
|
||||
{
|
||||
List<MyWorldObject> previouslySaved = ReadCache() ?? [];
|
||||
|
||||
var currentList = new List<MyWorldObject>();
|
||||
foreach (PluginInventoryItem item in _reader.CaptureOwned())
|
||||
{
|
||||
MyWorldObject current = _reader.Build(item);
|
||||
bool needsId = requestIdsIfMissing
|
||||
&& !current.HasIdData
|
||||
&& InventoryReader.NeedsIdent(item.ObjectClass, item.Name);
|
||||
|
||||
MyWorldObject? previous = previouslySaved.Find(
|
||||
prev => prev.Id == current.Id && prev.ObjectClass == current.ObjectClass);
|
||||
if (previous is not null)
|
||||
{
|
||||
if (needsId && !previous.HasIdData)
|
||||
{
|
||||
_identify.Enqueue(item.ObjectId);
|
||||
currentList.Add(current);
|
||||
}
|
||||
else
|
||||
{
|
||||
currentList.Add(DecalItemBuilder.Combine(previous, current));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (needsId)
|
||||
_identify.Enqueue(item.ObjectId);
|
||||
currentList.Add(current);
|
||||
}
|
||||
|
||||
WriteCache(currentList);
|
||||
|
||||
if (_context.Settings.Current.WebSocketEnabled)
|
||||
{
|
||||
_context.Send(new
|
||||
{
|
||||
type = "full_inventory",
|
||||
timestamp = Wire.Timestamp(_context.Clock),
|
||||
character_name = _context.CharacterName,
|
||||
item_count = currentList.Count,
|
||||
items = currentList,
|
||||
});
|
||||
_context.Chat.Write("Inventory sent to MosswartOverlord");
|
||||
}
|
||||
}
|
||||
|
||||
private List<MyWorldObject>? ReadCache()
|
||||
{
|
||||
IPluginStorage? scope = CacheScope();
|
||||
string? text = scope?.ReadText(CacheKey);
|
||||
if (text is null)
|
||||
return null;
|
||||
try
|
||||
{
|
||||
return JsonConvert.DeserializeObject<List<MyWorldObject>>(text) ?? [];
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
_context.Chat.Write("Inventory file is corrupt.");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private void WriteCache(List<MyWorldObject> items)
|
||||
{
|
||||
IPluginStorage? scope = CacheScope();
|
||||
if (scope is null)
|
||||
return;
|
||||
try
|
||||
{
|
||||
scope.WriteText(CacheKey, JsonConvert.SerializeObject(items, Formatting.Indented));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_context.Host.Log.Warn($"The inventory copy could not be saved: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private IPluginStorage? CacheScope()
|
||||
{
|
||||
IPluginStorage storage = _context.Host.Storage;
|
||||
return storage.IsAvailable
|
||||
? storage.OpenScope(PluginStorageScope.Character(_context.CharacterName))
|
||||
: null;
|
||||
}
|
||||
|
||||
private void OnObjectChanged(PluginObjectChange change)
|
||||
{
|
||||
if (!_active)
|
||||
return;
|
||||
try
|
||||
{
|
||||
if (change.Kind == PluginObjectChangeKind.IdentReceived)
|
||||
{
|
||||
_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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>An item of the login stream: tracked without an add, appraised if worth it.</summary>
|
||||
private void OnLoginStreamItem(PluginWorldObject wo)
|
||||
{
|
||||
if (!_reader.IsPlayerInventory(wo.ContainerObjectId, wo.WielderObjectId))
|
||||
return;
|
||||
if (_tracked.Add(wo.ObjectId))
|
||||
_quietSeconds = 0d;
|
||||
if (!IsIdentified(wo) && InventoryReader.NeedsIdent(wo.ObjectClass, wo.Name))
|
||||
_identify.Enqueue(wo.ObjectId);
|
||||
}
|
||||
|
||||
private void OnCreate(PluginWorldObject wo)
|
||||
{
|
||||
if (!_reader.IsPlayerInventory(wo.ContainerObjectId, wo.WielderObjectId))
|
||||
return;
|
||||
if (_tracked.Contains(wo.ObjectId))
|
||||
return;
|
||||
AddTracked(wo);
|
||||
}
|
||||
|
||||
private void OnChange(PluginWorldObject wo)
|
||||
{
|
||||
if (!_reader.IsPlayerInventory(wo.ContainerObjectId, wo.WielderObjectId))
|
||||
{
|
||||
// The item left the inventory.
|
||||
if (_tracked.Remove(wo.ObjectId))
|
||||
SendRemove(wo.ObjectId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_tracked.Contains(wo.ObjectId))
|
||||
AddTracked(wo);
|
||||
else
|
||||
_dirty.Add(wo.ObjectId);
|
||||
}
|
||||
|
||||
private void OnRelease(uint objectId)
|
||||
{
|
||||
if (!_settled)
|
||||
{
|
||||
_tracked.Remove(objectId);
|
||||
return;
|
||||
}
|
||||
if (_tracked.Remove(objectId))
|
||||
SendRemove(objectId);
|
||||
}
|
||||
|
||||
private void AddTracked(PluginWorldObject wo)
|
||||
{
|
||||
_tracked.Add(wo.ObjectId);
|
||||
if (InventoryReader.TryFind(_reader.CaptureOwned(), wo.ObjectId, out PluginInventoryItem item))
|
||||
SendDelta("add", _reader.Build(item));
|
||||
|
||||
if (!IsIdentified(wo) && InventoryReader.NeedsIdent(wo.ObjectClass, wo.Name))
|
||||
_identify.Enqueue(wo.ObjectId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The inventory logger's own request: an unappraised item that lands in
|
||||
/// the main pack (or is wielded) is appraised once.
|
||||
/// </summary>
|
||||
private void RequestIdForMainPackItem(PluginWorldObject wo)
|
||||
{
|
||||
if (!InventoryLog || _waitingForLoginIds || _waitingForForcedIds)
|
||||
return;
|
||||
uint container = wo.ContainerObjectId != 0u ? wo.ContainerObjectId : wo.WielderObjectId;
|
||||
if (!IsIdentified(wo)
|
||||
&& InventoryReader.NeedsIdent(wo.ObjectClass, wo.Name)
|
||||
&& container == _reader.CharacterId
|
||||
&& _requested.Add(wo.ObjectId))
|
||||
{
|
||||
_identify.Enqueue(wo.ObjectId);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsIdentified(PluginWorldObject wo) => wo.HasAppraisalData && wo.LastIdTime != 0;
|
||||
|
||||
private void ArmFlushTimer()
|
||||
{
|
||||
_flushTimer?.Dispose();
|
||||
_flushTimer = _context.Scheduler.Delay(TimeSpan.FromMilliseconds(NextFlushIntervalMs()), FlushDirtyItems);
|
||||
}
|
||||
|
||||
/// <summary>Two minutes plus up to three more, re-rolled for every flush.</summary>
|
||||
internal int NextFlushIntervalMs() => FlushBaseMs + _rng.Next(0, FlushJitterMs + 1);
|
||||
|
||||
/// <summary>Sends the current state of every item that changed since the last flush.</summary>
|
||||
internal void FlushDirtyItems()
|
||||
{
|
||||
if (!_active)
|
||||
return;
|
||||
ArmFlushTimer();
|
||||
if (_dirty.Count == 0)
|
||||
return;
|
||||
|
||||
var ids = new List<uint>(_dirty);
|
||||
_dirty.Clear();
|
||||
IReadOnlyList<PluginInventoryItem> owned = _reader.CaptureOwned();
|
||||
foreach (uint id in ids)
|
||||
{
|
||||
try
|
||||
{
|
||||
// An item that left since it was marked has had its remove.
|
||||
if (!_tracked.Contains(id))
|
||||
continue;
|
||||
if (!InventoryReader.TryFind(owned, id, out PluginInventoryItem item)
|
||||
|| !_reader.IsPlayerInventory(item.ContainerObjectId, item.WielderObjectId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
SendDelta("update", _reader.Build(item));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_context.Host.Log.Warn($"[LiveInv] Error flushing item {id}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void SendDelta(string action, MyWorldObject item) =>
|
||||
_context.Send(new
|
||||
{
|
||||
type = "inventory_delta",
|
||||
timestamp = Wire.Timestamp(_context.Clock),
|
||||
character_name = _context.CharacterName,
|
||||
action = action,
|
||||
item = item,
|
||||
});
|
||||
|
||||
private void SendRemove(uint objectId) =>
|
||||
_context.Send(new
|
||||
{
|
||||
type = "inventory_delta",
|
||||
timestamp = Wire.Timestamp(_context.Clock),
|
||||
character_name = _context.CharacterName,
|
||||
action = "remove",
|
||||
item_id = unchecked((int)objectId),
|
||||
});
|
||||
}
|
||||
146
src/OpenAC.MosswartMassacre/Inventory/InventoryReader.cs
Normal file
146
src/OpenAC.MosswartMassacre/Inventory/InventoryReader.cs
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
using AcDream.Plugin.Abstractions;
|
||||
|
||||
namespace OpenAC.MosswartMassacre.Inventory;
|
||||
|
||||
/// <summary>
|
||||
/// Reads the player's items from the host and turns them into item records.
|
||||
/// Tick thread only, like every host read.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The original's appraisal time was the wall clock in Unix seconds when the
|
||||
/// appraisal arrived. The host keeps its own session clock instead, so the
|
||||
/// reader stamps the wall clock the first time it sees each new appraisal of
|
||||
/// an item and reports that stamp from then on.
|
||||
/// </remarks>
|
||||
internal sealed class InventoryReader
|
||||
{
|
||||
public const string PrismaticTaper = "Prismatic Taper";
|
||||
|
||||
private readonly MmContext _context;
|
||||
private readonly IItemHeaderSource _headers;
|
||||
private readonly Dictionary<uint, (int HostTime, int UnixSeconds)> _idStamps = [];
|
||||
|
||||
public InventoryReader(MmContext context, IItemHeaderSource headers)
|
||||
{
|
||||
_context = context;
|
||||
_headers = headers;
|
||||
}
|
||||
|
||||
public uint CharacterId => _context.Automation.Character.ObjectId;
|
||||
|
||||
/// <summary>Forgets the appraisal stamps, for a new login.</summary>
|
||||
public void Reset() => _idStamps.Clear();
|
||||
|
||||
public IReadOnlyList<PluginInventoryItem> CaptureOwned() =>
|
||||
_context.Automation.Items.CaptureOwnedItems();
|
||||
|
||||
public static bool TryFind(IReadOnlyList<PluginInventoryItem> items, uint objectId, out PluginInventoryItem item)
|
||||
{
|
||||
foreach (PluginInventoryItem candidate in items)
|
||||
{
|
||||
if (candidate.ObjectId != objectId)
|
||||
continue;
|
||||
item = candidate;
|
||||
return true;
|
||||
}
|
||||
item = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True once the item has been appraised this session. Any property data
|
||||
/// makes the host report appraisal data, so the appraisal time is what
|
||||
/// tells a real appraisal from a lone property update.
|
||||
/// </summary>
|
||||
public bool IsIdentified(uint objectId) =>
|
||||
_context.Automation.Objects.TryGet(objectId, out PluginWorldObject world)
|
||||
&& world.HasAppraisalData
|
||||
&& world.LastIdTime != 0;
|
||||
|
||||
/// <summary>
|
||||
/// Stamps an appraisal as it arrives, so the record carries the time it
|
||||
/// came in rather than the time the record was first built.
|
||||
/// </summary>
|
||||
public void NoteAppraisal(uint objectId)
|
||||
{
|
||||
if (_context.Automation.Objects.TryGet(objectId, out PluginWorldObject world) && world.LastIdTime != 0)
|
||||
Stamp(objectId, world.LastIdTime);
|
||||
}
|
||||
|
||||
/// <summary>The item record for one owned item, as it stands now.</summary>
|
||||
public MyWorldObject Build(in PluginInventoryItem item)
|
||||
{
|
||||
bool identified = false;
|
||||
int lastIdTime = 0;
|
||||
if (_context.Automation.Objects.TryGet(item.ObjectId, out PluginWorldObject world)
|
||||
&& world.HasAppraisalData
|
||||
&& world.LastIdTime != 0)
|
||||
{
|
||||
identified = true;
|
||||
lastIdTime = Stamp(item.ObjectId, world.LastIdTime);
|
||||
}
|
||||
|
||||
PluginItemProperties? properties =
|
||||
_context.Automation.Items.TryCaptureProperties(item.ObjectId, out PluginItemProperties captured)
|
||||
? captured
|
||||
: null;
|
||||
|
||||
return DecalItemBuilder.Create(new ItemSource(
|
||||
item, properties, identified, lastIdTime, _headers.Read(item)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The original's test for an item worth appraising: armor, clothing,
|
||||
/// weapons, casters, jewelry, Aetheria and summoning essences.
|
||||
/// </summary>
|
||||
public static bool NeedsIdent(PluginObjectClass objectClass, string? name) =>
|
||||
objectClass == PluginObjectClass.Armor
|
||||
|| objectClass == PluginObjectClass.Clothing
|
||||
|| objectClass == PluginObjectClass.MeleeWeapon
|
||||
|| objectClass == PluginObjectClass.MissileWeapon
|
||||
|| objectClass == PluginObjectClass.WandStaffOrb
|
||||
|| objectClass == PluginObjectClass.Jewelry
|
||||
|| (objectClass == PluginObjectClass.Gem && !string.IsNullOrEmpty(name) && name.Contains("Aetheria", StringComparison.Ordinal))
|
||||
|| (objectClass == PluginObjectClass.Misc && !string.IsNullOrEmpty(name) && name.Contains("Essence", StringComparison.Ordinal));
|
||||
|
||||
/// <summary>
|
||||
/// The original's inventory test: the item sits in (or is wielded by) the
|
||||
/// character, or sits in a pack the character holds.
|
||||
/// </summary>
|
||||
public bool IsPlayerInventory(uint containerObjectId, uint wielderObjectId)
|
||||
{
|
||||
uint characterId = CharacterId;
|
||||
uint container = containerObjectId != 0u ? containerObjectId : wielderObjectId;
|
||||
if (characterId == 0u || container == 0u)
|
||||
return false;
|
||||
if (container == characterId)
|
||||
return true;
|
||||
return _context.Automation.Objects.TryGet(container, out PluginWorldObject pack)
|
||||
&& pack.ObjectClass == PluginObjectClass.Container
|
||||
&& pack.ContainerObjectId == characterId;
|
||||
}
|
||||
|
||||
/// <summary>The number of Prismatic Tapers in the packs.</summary>
|
||||
public int CountPrismaticTapers()
|
||||
{
|
||||
int count = 0;
|
||||
foreach (PluginInventoryItem item in CaptureOwned())
|
||||
{
|
||||
if (string.Equals(item.Name, PrismaticTaper, StringComparison.OrdinalIgnoreCase)
|
||||
&& IsPlayerInventory(item.ContainerObjectId, 0u))
|
||||
{
|
||||
count += item.StackSize;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private int Stamp(uint objectId, int hostTime)
|
||||
{
|
||||
if (_idStamps.TryGetValue(objectId, out var stamp) && stamp.HostTime == hostTime)
|
||||
return stamp.UnixSeconds;
|
||||
int unix = unchecked((int)new DateTimeOffset(_context.Clock.UtcNow).ToUnixTimeSeconds());
|
||||
_idStamps[objectId] = (hostTime, unix);
|
||||
return unix;
|
||||
}
|
||||
}
|
||||
441
src/OpenAC.MosswartMassacre/Inventory/ItemDictionaries.cs
Normal file
441
src/OpenAC.MosswartMassacre/Inventory/ItemDictionaries.cs
Normal file
|
|
@ -0,0 +1,441 @@
|
|||
namespace OpenAC.MosswartMassacre.Inventory;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal static class ItemDictionaries
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns a dictionary of skill ids vs names
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public static readonly Dictionary<int, string> SkillInfo = new Dictionary<int, string>
|
||||
{
|
||||
// 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" },
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Returns a dictionary of mastery ids vs names
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public static readonly Dictionary<int, string> MasteryInfo = new Dictionary<int, string>
|
||||
{
|
||||
{ 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" },
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Returns a dictionary of attribute set ids vs names
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public static readonly Dictionary<int, string> AttributeSetInfo = new Dictionary<int, string>
|
||||
{
|
||||
// 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
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Returns a dictionary of material ids vs names
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public static readonly Dictionary<int, string> MaterialInfo = new Dictionary<int, string>
|
||||
{
|
||||
{ 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<T>
|
||||
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<int, SpellInfo<int>> LongValueKeySpellEffects = new Dictionary<int, SpellInfo<int>>()
|
||||
{
|
||||
// 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<int>(LongValueKey_MaxDamage, 20)}, // Blood Drinker VI
|
||||
{ 2096, new SpellInfo<int>(LongValueKey_MaxDamage, 22)}, // Infected Caress
|
||||
//{ 5183, new SpellInfo<LongValueKey>(LongValueKey_MaxDamage, 22)}, // Incantation of Blood Drinker Pre Feb-2013
|
||||
//{ 4395, new SpellInfo<LongValueKey>(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<int>(LongValueKey_MaxDamage, 24)}, // Incantation of Blood Drinker Post Feb-2013
|
||||
{ 4395, new SpellInfo<int>(LongValueKey_MaxDamage, 24)}, // Incantation of Blood Drinker Post Feb-2013
|
||||
|
||||
{ 2598, new SpellInfo<int>(LongValueKey_MaxDamage, 2, 2)}, // Minor Blood Thirst
|
||||
{ 2586, new SpellInfo<int>(LongValueKey_MaxDamage, 4, 4)}, // Major Blood Thirst
|
||||
{ 4661, new SpellInfo<int>(LongValueKey_MaxDamage, 7, 7)}, // Epic Blood Thirst
|
||||
{ 6089, new SpellInfo<int>(LongValueKey_MaxDamage, 10, 10)}, // Legendary Blood Thirst
|
||||
|
||||
{ 3688, new SpellInfo<int>(LongValueKey_MaxDamage, 300)}, // Prodigal Blood Drinker
|
||||
|
||||
|
||||
{ 1486, new SpellInfo<int>(LongValueKey_ArmorLevel, 200)}, // Impenetrability VI
|
||||
{ 2108, new SpellInfo<int>(LongValueKey_ArmorLevel, 220)}, // Brogard's Defiance
|
||||
{ 4407, new SpellInfo<int>(LongValueKey_ArmorLevel, 240)}, // Incantation of Impenetrability
|
||||
|
||||
{ 2604, new SpellInfo<int>(LongValueKey_ArmorLevel, 20, 20)}, // Minor Impenetrability
|
||||
{ 2592, new SpellInfo<int>(LongValueKey_ArmorLevel, 40, 40)}, // Major Impenetrability
|
||||
{ 4667, new SpellInfo<int>(LongValueKey_ArmorLevel, 60, 60)}, // Epic Impenetrability
|
||||
{ 6095, new SpellInfo<int>(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<int, SpellInfo<double>> DoubleValueKeySpellEffects = new Dictionary<int, SpellInfo<double>>()
|
||||
{
|
||||
// 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<double>(DoubleValueKey_ElementalDamageVersusMonsters, .06)}, // Spirit Drinker VI
|
||||
{ 3259, new SpellInfo<double>(DoubleValueKey_ElementalDamageVersusMonsters, .07)}, // Infected Spirit Caress
|
||||
//{ 5182, new SpellInfo<double>(DoubleValueKey_ElementalDamageVersusMonsters, .07)}, // Incantation of Spirit Drinker Pre Feb-2013
|
||||
//{ 4414, new SpellInfo<double>(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<double>(DoubleValueKey_ElementalDamageVersusMonsters, .08)}, // Incantation of Spirit Drinker Post Feb-2013
|
||||
{ 4414, new SpellInfo<double>(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<double>(DoubleValueKey_ElementalDamageVersusMonsters, .01, .01)}, // Minor Spirit Thirst
|
||||
{ 3250, new SpellInfo<double>(DoubleValueKey_ElementalDamageVersusMonsters, .03, .03)}, // Major Spirit Thirst
|
||||
{ 4670, new SpellInfo<double>(DoubleValueKey_ElementalDamageVersusMonsters, .05, .05)}, // Epic Spirit Thirst
|
||||
{ 6098, new SpellInfo<double>(DoubleValueKey_ElementalDamageVersusMonsters, .07, .07)}, // Legendary Spirit Thirst
|
||||
|
||||
{ 3735, new SpellInfo<double>(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<double>(DoubleValueKey_AttackBonus, .15)}, // Heart Seeker VI
|
||||
{ 2106, new SpellInfo<double>(DoubleValueKey_AttackBonus, .17)}, // Elysa's Sight
|
||||
{ 4405, new SpellInfo<double>(DoubleValueKey_AttackBonus, .20)}, // Incantation of Heart Seeker
|
||||
|
||||
{ 2603, new SpellInfo<double>(DoubleValueKey_AttackBonus, .03, .03)}, // Minor Heart Thirst
|
||||
{ 2591, new SpellInfo<double>(DoubleValueKey_AttackBonus, .05, .05)}, // Major Heart Thirst
|
||||
{ 4666, new SpellInfo<double>(DoubleValueKey_AttackBonus, .07, .07)}, // Epic Heart Thirst
|
||||
{ 6094, new SpellInfo<double>(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<double>(DoubleValueKey_MeleeDefenseBonus, .15)}, // Defender VI
|
||||
{ 2101, new SpellInfo<double>(DoubleValueKey_MeleeDefenseBonus, .17)}, // Cragstone's Will
|
||||
//{ 4400, new SpellInfo<double>(DoubleValueKey_MeleeDefenseBonus, .17)}, // Incantation of Defender Pre Feb-2013
|
||||
{ 4400, new SpellInfo<double>(DoubleValueKey_MeleeDefenseBonus, .20)}, // Incantation of Defender Post Feb-2013
|
||||
|
||||
{ 2600, new SpellInfo<double>(DoubleValueKey_MeleeDefenseBonus, .03, .03)}, // Minor Defender
|
||||
{ 3985, new SpellInfo<double>(DoubleValueKey_MeleeDefenseBonus, .04, .04)}, // Mukkir Sense
|
||||
{ 2588, new SpellInfo<double>(DoubleValueKey_MeleeDefenseBonus, .05, .05)}, // Major Defender
|
||||
{ 4663, new SpellInfo<double>(DoubleValueKey_MeleeDefenseBonus, .07, .07)}, // Epic Defender
|
||||
{ 6091, new SpellInfo<double>(DoubleValueKey_MeleeDefenseBonus, .09, .09)}, // Legendary Defender
|
||||
|
||||
{ 3699, new SpellInfo<double>(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<double>(DoubleValueKey_ManaCBonus, 1.60)}, // Hermetic Link VI
|
||||
{ 2117, new SpellInfo<double>(DoubleValueKey_ManaCBonus, 1.70)}, // Mystic's Blessing
|
||||
{ 4418, new SpellInfo<double>(DoubleValueKey_ManaCBonus, 1.80)}, // Incantation of Hermetic Link
|
||||
|
||||
{ 3201, new SpellInfo<double>(DoubleValueKey_ManaCBonus, 1.05, 1.05)}, // Feeble Hermetic Link
|
||||
{ 3199, new SpellInfo<double>(DoubleValueKey_ManaCBonus, 1.10, 1.10)}, // Minor Hermetic Link
|
||||
{ 3202, new SpellInfo<double>(DoubleValueKey_ManaCBonus, 1.15, 1.15)}, // Moderate Hermetic Link
|
||||
{ 3200, new SpellInfo<double>(DoubleValueKey_ManaCBonus, 1.20, 1.20)}, // Major Hermetic Link
|
||||
{ 6086, new SpellInfo<double>(DoubleValueKey_ManaCBonus, 1.25, 1.25)}, // Epic Hermetic Link
|
||||
{ 6087, new SpellInfo<double>(DoubleValueKey_ManaCBonus, 1.30, 1.30)}, // Legendary Hermetic Link
|
||||
};
|
||||
}
|
||||
76
src/OpenAC.MosswartMassacre/Inventory/ItemHeaderSource.cs
Normal file
76
src/OpenAC.MosswartMassacre/Inventory/ItemHeaderSource.cs
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
using AcDream.Plugin.Abstractions;
|
||||
|
||||
namespace OpenAC.MosswartMassacre.Inventory;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <see cref="WeenieHeaderFlags"/> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The physics words come from the object's physics description, which
|
||||
/// the contract does not expose at all.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal readonly record struct ItemHeaderWords
|
||||
{
|
||||
/// <summary>The object header's first flag word (key 218103832).</summary>
|
||||
public uint? WeenieHeaderFlags { get; init; }
|
||||
|
||||
/// <summary>The object header's second flag word (key 218103833), when the header has one.</summary>
|
||||
public uint? WeenieHeaderFlags2 { get; init; }
|
||||
|
||||
/// <summary>The physics description's flag word (key 218103847).</summary>
|
||||
public uint? PhysicsDescriptionFlags { get; init; }
|
||||
|
||||
/// <summary>The physics word the framework filed under key 218103831.</summary>
|
||||
public uint? PhysicsWord { get; init; }
|
||||
|
||||
/// <summary>The model setup id (key 218103830).</summary>
|
||||
public uint? SetupId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The value the framework filed under its scale key (167772170). It was
|
||||
/// 0.0 in every recorded item whose physics description carried a scale.
|
||||
/// </summary>
|
||||
public double? Scale { get; init; }
|
||||
|
||||
/// <summary>The object a wielded item hangs from (key 218103818).</summary>
|
||||
public uint? ParentObjectId { get; init; }
|
||||
|
||||
/// <summary>Where on its parent a wielded item hangs (key 218103819).</summary>
|
||||
public int? ParentLocation { get; init; }
|
||||
|
||||
/// <summary>The header's hook type (key 218103828).</summary>
|
||||
public int? HookType { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Reads the header words for one owned item.</summary>
|
||||
internal interface IItemHeaderSource
|
||||
{
|
||||
ItemHeaderWords Read(in PluginInventoryItem item);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The contract this plugin builds against exposes none of these words, so
|
||||
/// every one is absent. Replace this when the contract grows them.
|
||||
/// </summary>
|
||||
internal sealed class HostItemHeaderSource : IItemHeaderSource
|
||||
{
|
||||
public static HostItemHeaderSource Instance { get; } = new();
|
||||
|
||||
private HostItemHeaderSource()
|
||||
{
|
||||
}
|
||||
|
||||
public ItemHeaderWords Read(in PluginInventoryItem item) => default;
|
||||
}
|
||||
313
src/OpenAC.MosswartMassacre/Inventory/MyWorldObject.cs
Normal file
313
src/OpenAC.MosswartMassacre/Inventory/MyWorldObject.cs
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
using System.Globalization;
|
||||
|
||||
namespace OpenAC.MosswartMassacre.Inventory;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <c>full_inventory</c> and <c>inventory_delta</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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 <c>-1</c> is written
|
||||
/// <c>-1.0</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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 <see cref="DecalKeys"/>). <see cref="DecalItemBuilder"/> fills them.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class MyWorldObject
|
||||
{
|
||||
public bool HasIdData;
|
||||
public int Id;
|
||||
public int LastIdTime;
|
||||
public int ObjectClass;
|
||||
public int Icon;
|
||||
public int ContainerId;
|
||||
|
||||
public Dictionary<int, bool> BoolValues = [];
|
||||
public Dictionary<int, double> DoubleValues = [];
|
||||
public Dictionary<int, int> IntValues = [];
|
||||
public Dictionary<int, string> StringValues = [];
|
||||
|
||||
public List<int> ActiveSpells = [];
|
||||
public List<int> Spells = [];
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public void AddTo(
|
||||
IDictionary<int, bool> boolValues,
|
||||
IDictionary<int, double> doubleValues,
|
||||
IDictionary<int, int> intValues,
|
||||
IDictionary<int, string> stringValues)
|
||||
{
|
||||
foreach (KeyValuePair<int, bool> kvp in boolValues)
|
||||
BoolValues[kvp.Key] = kvp.Value;
|
||||
foreach (KeyValuePair<int, double> kvp in doubleValues)
|
||||
DoubleValues[kvp.Key] = kvp.Value;
|
||||
foreach (KeyValuePair<int, int> kvp in intValues)
|
||||
IntValues[kvp.Key] = kvp.Value;
|
||||
foreach (KeyValuePair<int, string> 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; } }
|
||||
|
||||
/// <summary>The sum of every rating on the item, or -1 when it carries none.</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The armor level less buffs and tinks (20 each, the imbue excluded), plus
|
||||
/// any impenetrability cantrip; a non-loot item (no material) loses 200.
|
||||
/// </summary>
|
||||
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<int, ItemDictionaries.SpellInfo<int>> effect in ItemDictionaries.LongValueKeySpellEffects)
|
||||
{
|
||||
if (spell == effect.Key && effect.Value.Key == 28)
|
||||
armorFromBuffs += effect.Value.Change;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (int spell in Spells)
|
||||
{
|
||||
foreach (KeyValuePair<int, ItemDictionaries.SpellInfo<int>> effect in ItemDictionaries.LongValueKeySpellEffects)
|
||||
{
|
||||
if (spell == effect.Key && effect.Value.Key == 28)
|
||||
armorFromBuffs -= effect.Value.Bonus;
|
||||
}
|
||||
}
|
||||
|
||||
return ArmorLevel - armorFromTinks - armorFromBuffs;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A melee weapon's best damage over time after ten tinks, from its variance, max damage and tinks.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>maxDamage * ((1 - critChance) * (2 - variance) / 2 + (critChance * critMultiplier)).</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue