Inventory: full_inventory, inventory_delta, equipment_cantrip_state
The backend's inventory views and mana panel are fed by three streams the original sent: the whole inventory after login, at logoff and on /mm sendinventory; an add, update or remove per item as it enters, changes in or leaves the packs; and the per-item cantrip state of worn and wielded gear. This ports all three onto the host's object events, with the frames built exactly as the original built them. Behaviour kept: adds and removes go out at once, changes are coalesced into one update per item on a two-to-five-minute timer re-rolled per flush; the first login appraises everything worth appraising and waits before sending; later logins merge in the saved appraisal data (kept in the character's storage, as the original kept a file per character); the mana panel debounces a quarter second and projects mana down between reports. Adapted to the host: appraisals go through a tick-driven queue, one at a time, retrying while the host's single slot is busy; the login upload waits for the inventory to stop arriving and for the socket, so it is not written into a closed socket; the logoff upload subscribes ahead of the root so the socket is still open; enchantment changes are polled once a second (the host has no event); an item-cast enchantment is the one that never runs out. A request the server never answers no longer holds the upload back for good. The Prismatic Taper count telemetry reads is InventoryFeature.PrismaticTaperCount. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f4a44bd9fc
commit
7a6b0a7f9d
11 changed files with 2206 additions and 2 deletions
|
|
@ -1,4 +1,5 @@
|
||||||
using OpenAC.MosswartMassacre.Backend;
|
using OpenAC.MosswartMassacre.Backend;
|
||||||
|
using OpenAC.MosswartMassacre.Inventory;
|
||||||
|
|
||||||
namespace OpenAC.MosswartMassacre;
|
namespace OpenAC.MosswartMassacre;
|
||||||
|
|
||||||
|
|
@ -10,8 +11,13 @@ internal static class FeatureCatalog
|
||||||
{
|
{
|
||||||
public static void AddFeatures(MmContext context, BackendSession session, List<IMmFeature> features)
|
public static void AddFeatures(MmContext context, BackendSession session, List<IMmFeature> features)
|
||||||
{
|
{
|
||||||
_ = context;
|
|
||||||
_ = session;
|
_ = session;
|
||||||
_ = features;
|
|
||||||
|
// Inventory: full_inventory, inventory_delta, equipment_cantrip_state,
|
||||||
|
// and the taper count telemetry reads (InventoryFeature.PrismaticTaperCount).
|
||||||
|
var inventoryReader = new InventoryReader(context, HostItemHeaderSource.Instance);
|
||||||
|
var identifyQueue = new IdentifyQueue(() => context.Automation);
|
||||||
|
features.Add(new InventoryFeature(context, inventoryReader, identifyQueue));
|
||||||
|
features.Add(new EquipmentCantripStateFeature(context, inventoryReader));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
602
src/OpenAC.MosswartMassacre/Inventory/InventoryFeature.cs
Normal file
602
src/OpenAC.MosswartMassacre/Inventory/InventoryFeature.cs
Normal file
|
|
@ -0,0 +1,602 @@
|
||||||
|
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;
|
||||||
|
private readonly Action _onHostLogoff;
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
_onHostLogoff = OnHostLogoff;
|
||||||
|
_context.Host.Events.ObjectChanged += _onObjectChanged;
|
||||||
|
_context.Host.Events.Tick += _onTick;
|
||||||
|
// Subscribed while the root builds its features, which is before the
|
||||||
|
// root subscribes its own logoff; so this runs while the socket is
|
||||||
|
// still open, and the logoff inventory can still be sent.
|
||||||
|
_context.Host.Events.Logoff += _onHostLogoff;
|
||||||
|
|
||||||
|
context.Commands.Register("sendinventory", _ => ForceInventoryUpload(), "Force full inventory upload");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnLogoff()
|
||||||
|
{
|
||||||
|
_active = false;
|
||||||
|
ResetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_context.Host.Events.ObjectChanged -= _onObjectChanged;
|
||||||
|
_context.Host.Events.Tick -= _onTick;
|
||||||
|
_context.Host.Events.Logoff -= _onHostLogoff;
|
||||||
|
ResetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ResetState()
|
||||||
|
{
|
||||||
|
_flushTimer?.Dispose();
|
||||||
|
_flushTimer = null;
|
||||||
|
_tracked.Clear();
|
||||||
|
_dirty.Clear();
|
||||||
|
_requested.Clear();
|
||||||
|
_identify.Clear();
|
||||||
|
_waitingForLoginIds = false;
|
||||||
|
_waitingForForcedIds = false;
|
||||||
|
_settled = false;
|
||||||
|
_loginUploadPending = false;
|
||||||
|
_quietSeconds = 0d;
|
||||||
|
_settleSeconds = 0d;
|
||||||
|
_connectSeconds = 0d;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool InventoryLog => _context.Settings.Current.InventoryLog;
|
||||||
|
|
||||||
|
private void OnTick(double deltaSeconds)
|
||||||
|
{
|
||||||
|
if (!_active)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_identify.Pump(deltaSeconds);
|
||||||
|
|
||||||
|
if (!_settled)
|
||||||
|
{
|
||||||
|
_quietSeconds += deltaSeconds;
|
||||||
|
_settleSeconds += deltaSeconds;
|
||||||
|
if (_quietSeconds >= SettleQuiet.TotalSeconds || _settleSeconds >= SettleLimit.TotalSeconds)
|
||||||
|
Settle();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_loginUploadPending)
|
||||||
|
{
|
||||||
|
_connectSeconds += deltaSeconds;
|
||||||
|
if (!_context.Settings.Current.WebSocketEnabled || IsBackendReady() || _connectSeconds >= ConnectLimit.TotalSeconds)
|
||||||
|
{
|
||||||
|
_loginUploadPending = false;
|
||||||
|
RunLoginUpload();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((_waitingForLoginIds || _waitingForForcedIds) && _identify.IsIdle)
|
||||||
|
CompleteWaitingUpload();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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)
|
||||||
|
_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),
|
||||||
|
});
|
||||||
|
}
|
||||||
136
src/OpenAC.MosswartMassacre/Inventory/InventoryReader.cs
Normal file
136
src/OpenAC.MosswartMassacre/Inventory/InventoryReader.cs
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
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>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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,114 @@
|
||||||
|
using AcDream.Plugin.Abstractions;
|
||||||
|
using OpenAC.MosswartMassacre.Backend;
|
||||||
|
using OpenAC.MosswartMassacre.Inventory;
|
||||||
|
using static OpenAC.MosswartMassacre.Tests.Inventory.InventoryWorld;
|
||||||
|
|
||||||
|
namespace OpenAC.MosswartMassacre.Tests.Inventory;
|
||||||
|
|
||||||
|
public sealed class DecalItemBuilderTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void With_the_header_flags_known_a_field_sent_as_zero_is_present_and_an_unsent_one_absent()
|
||||||
|
{
|
||||||
|
// The original's dumps hold "218103822": 0 for items whose header
|
||||||
|
// carried a zero equipable-slots field (1153 of 3058 items).
|
||||||
|
PluginInventoryItem tapers = Tapers();
|
||||||
|
uint flags = HeaderFlags.Value | HeaderFlags.Usable | HeaderFlags.ValidLocations
|
||||||
|
| HeaderFlags.StackSize | HeaderFlags.MaxStackSize | HeaderFlags.PluralName;
|
||||||
|
|
||||||
|
MyWorldObject record = DecalItemBuilder.Create(new ItemSource(
|
||||||
|
tapers, null, false, 0, new ItemHeaderWords { WeenieHeaderFlags = flags }));
|
||||||
|
|
||||||
|
Assert.Equal(0, record.IntValues[DecalKeys.EquipableSlots]);
|
||||||
|
Assert.Equal(unchecked((int)flags), record.IntValues[DecalKeys.CreateFlags1]);
|
||||||
|
Assert.False(record.IntValues.ContainsKey(DecalKeys.RawBurden));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Without_the_header_flags_a_zero_field_is_left_out_and_the_value_key_reads_minus_one()
|
||||||
|
{
|
||||||
|
PluginInventoryItem bare = FakeItemsBare(0x80000555u, "Bare Thing");
|
||||||
|
|
||||||
|
MyWorldObject record = DecalItemBuilder.Create(new ItemSource(bare, null, false, 0, default));
|
||||||
|
|
||||||
|
Assert.Equal(-1, record.IntValues[DecalKeys.RawValue]);
|
||||||
|
Assert.Equal(-1, record.Value);
|
||||||
|
Assert.False(record.IntValues.ContainsKey(DecalKeys.EquipableSlots));
|
||||||
|
Assert.False(record.IntValues.ContainsKey(DecalKeys.CreateFlags1));
|
||||||
|
Assert.False(record.IntValues.ContainsKey(DecalKeys.PhysicsDataFlags));
|
||||||
|
Assert.False(record.IntValues.ContainsKey(DecalKeys.Setup));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_physics_and_second_header_words_are_written_when_known()
|
||||||
|
{
|
||||||
|
PluginInventoryItem tachi = Tachi(appraised: false);
|
||||||
|
var words = new ItemHeaderWords
|
||||||
|
{
|
||||||
|
WeenieHeaderFlags2 = 1u,
|
||||||
|
PhysicsDescriptionFlags = 0x21881u,
|
||||||
|
PhysicsWord = 17u,
|
||||||
|
SetupId = 0x02000155u,
|
||||||
|
Scale = 0d,
|
||||||
|
ParentObjectId = Me,
|
||||||
|
ParentLocation = 1,
|
||||||
|
HookType = 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
MyWorldObject record = DecalItemBuilder.Create(new ItemSource(tachi, null, false, 0, words));
|
||||||
|
|
||||||
|
Assert.Equal(1, record.IntValues[DecalKeys.CreateFlags2]);
|
||||||
|
Assert.Equal(0x21881, record.IntValues[DecalKeys.PhysicsDataFlags]);
|
||||||
|
Assert.Equal(17, record.IntValues[DecalKeys.PhysicsWord]);
|
||||||
|
Assert.Equal(0x02000155, record.IntValues[DecalKeys.Setup]);
|
||||||
|
Assert.Equal(0d, record.DoubleValues[DecalKeys.Scale]);
|
||||||
|
Assert.Equal(unchecked((int)Me), record.IntValues[DecalKeys.Wielder]);
|
||||||
|
Assert.Equal(1, record.IntValues[DecalKeys.WieldingSlot]);
|
||||||
|
Assert.Equal(2, record.IntValues[DecalKeys.HookType]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Combine_keeps_an_earlier_appraisal_and_refreshes_it_from_the_live_item()
|
||||||
|
{
|
||||||
|
// MyWorldObjectCreator.cs:44-61.
|
||||||
|
MyWorldObject older = DecalItemBuilder.Create(new ItemSource(
|
||||||
|
Tachi(appraised: true), TachiProps(), true, 1700000000, default));
|
||||||
|
PluginInventoryItem moved = Tachi(appraised: false) with { WielderObjectId = 0u, ContainerObjectId = 0x80000900u };
|
||||||
|
MyWorldObject newer = DecalItemBuilder.Create(new ItemSource(moved, null, false, 0, default));
|
||||||
|
|
||||||
|
MyWorldObject combined = DecalItemBuilder.Combine(older, newer);
|
||||||
|
|
||||||
|
Assert.Same(older, combined);
|
||||||
|
Assert.True(combined.HasIdData);
|
||||||
|
Assert.Equal(1700000000, combined.LastIdTime);
|
||||||
|
Assert.Equal([2598, 2603], combined.Spells);
|
||||||
|
Assert.Equal(50, combined.MaxDamage);
|
||||||
|
Assert.Equal(unchecked((int)0x80000900u), combined.ContainerId);
|
||||||
|
Assert.Equal(unchecked((int)0x80000900u), combined.IntValues[DecalKeys.Container]);
|
||||||
|
Assert.Equal(combined.IntValues.Keys.Order(), combined.IntValues.Keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Combine_takes_the_live_item_when_it_is_appraised_or_the_old_one_was_not()
|
||||||
|
{
|
||||||
|
MyWorldObject older = DecalItemBuilder.Create(new ItemSource(Tapers(), null, false, 0, default));
|
||||||
|
MyWorldObject newer = DecalItemBuilder.Create(new ItemSource(Tapers(60), null, false, 0, default));
|
||||||
|
|
||||||
|
Assert.Same(newer, DecalItemBuilder.Combine(older, newer));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_saved_record_reads_back_and_writes_the_same_frame()
|
||||||
|
{
|
||||||
|
MyWorldObject record = DecalItemBuilder.Create(new ItemSource(
|
||||||
|
Breastplate(), BreastplateProps(), true, 1790325015, default));
|
||||||
|
string json = Wire.Serialize(record);
|
||||||
|
|
||||||
|
MyWorldObject? back = Newtonsoft.Json.JsonConvert.DeserializeObject<MyWorldObject>(json);
|
||||||
|
|
||||||
|
Assert.Equal(json, Wire.Serialize(back));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PluginInventoryItem FakeItemsBare(uint id, string name) =>
|
||||||
|
Fakes.FakeItems.Item(id, name) with { ObjectClass = PluginObjectClass.Misc, MaximumStackSize = 1 };
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
using AcDream.Plugin.Abstractions;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using OpenAC.MosswartMassacre.Inventory;
|
||||||
|
using static OpenAC.MosswartMassacre.Tests.Inventory.InventoryWorld;
|
||||||
|
|
||||||
|
namespace OpenAC.MosswartMassacre.Tests.Inventory;
|
||||||
|
|
||||||
|
public sealed class EquipmentCantripStateTests
|
||||||
|
{
|
||||||
|
private static PluginHarness Arrange(params PluginActiveEnchantment[] enchantments) =>
|
||||||
|
new("Mossy", arrange: host =>
|
||||||
|
{
|
||||||
|
Own(host, Tachi(appraised: true), TachiProps(), hostIdTime: 5000);
|
||||||
|
host.Automation.Spells.All =
|
||||||
|
[
|
||||||
|
Spell(2598, family: 100, difficulty: 150),
|
||||||
|
Spell(2603, family: 101, difficulty: 150),
|
||||||
|
Spell(5183, family: 200, difficulty: 400),
|
||||||
|
Spell(6089, family: 100, difficulty: 400),
|
||||||
|
Spell(6094, family: 101, difficulty: 400),
|
||||||
|
];
|
||||||
|
host.Automation.Character.ActiveEnchantments = enchantments;
|
||||||
|
});
|
||||||
|
|
||||||
|
private static JToken OnlyItem(PluginHarness harness) =>
|
||||||
|
JObject.Parse(harness.FramesOfTypeRaw("equipment_cantrip_state").Last())["items"]!.Single();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_timed_buff_does_not_keep_an_item_spell_running_only_an_item_cast_one_does()
|
||||||
|
{
|
||||||
|
using var harness = Arrange(
|
||||||
|
new PluginActiveEnchantment(6089, 100, 8, double.PositiveInfinity),
|
||||||
|
new PluginActiveEnchantment(6094, 101, 8, 1800d));
|
||||||
|
harness.Login();
|
||||||
|
harness.Feature<EquipmentCantripStateFeature>().SendSnapshot();
|
||||||
|
|
||||||
|
JToken item = OnlyItem(harness);
|
||||||
|
Assert.Equal("not_active", (string)item["state"]!);
|
||||||
|
Assert.Equal(1500, (int)item["current_mana"]!);
|
||||||
|
Assert.Equal(JTokenType.Null, item["mana_time_remaining_seconds"]!.Type);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void An_item_without_mana_is_not_active_and_one_without_spells_is_left_out()
|
||||||
|
{
|
||||||
|
using var harness = Arrange();
|
||||||
|
PluginItemProperties empty = TachiProps();
|
||||||
|
var ints = new Dictionary<uint, int>(empty.Ints) { [107] = 0 };
|
||||||
|
Own(harness.Host, Tachi(appraised: true), empty with { Ints = ints }, hostIdTime: 5000);
|
||||||
|
harness.Login();
|
||||||
|
harness.Feature<EquipmentCantripStateFeature>().SendSnapshot();
|
||||||
|
Assert.Equal("not_active", (string)OnlyItem(harness)["state"]!);
|
||||||
|
|
||||||
|
Own(harness.Host, Tachi(appraised: false) with { AppraisedSpellIds = [] }, TachiProps(), hostIdTime: 5000);
|
||||||
|
harness.Feature<EquipmentCantripStateFeature>().SendSnapshot();
|
||||||
|
Assert.Empty(JObject.Parse(harness.FramesOfTypeRaw("equipment_cantrip_state").Last())["items"]!);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Changes_within_a_quarter_second_send_one_frame()
|
||||||
|
{
|
||||||
|
using var harness = Arrange();
|
||||||
|
harness.Login();
|
||||||
|
harness.Tick(0.3);
|
||||||
|
harness.Frames.Clear();
|
||||||
|
|
||||||
|
Raise(harness.Host, 0x80000202u, PluginObjectChangeKind.Updated);
|
||||||
|
harness.Tick(0.1);
|
||||||
|
Raise(harness.Host, 0x80000202u, PluginObjectChangeKind.Updated);
|
||||||
|
harness.Tick(0.2);
|
||||||
|
Assert.Empty(harness.FramesOfTypeRaw("equipment_cantrip_state"));
|
||||||
|
harness.Tick(0.1);
|
||||||
|
|
||||||
|
Assert.Single(harness.FramesOfTypeRaw("equipment_cantrip_state"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_change_in_the_characters_enchantments_sends_a_frame()
|
||||||
|
{
|
||||||
|
using var harness = Arrange();
|
||||||
|
harness.Login();
|
||||||
|
harness.Tick(0.3);
|
||||||
|
harness.Frames.Clear();
|
||||||
|
|
||||||
|
harness.Tick(1.0);
|
||||||
|
Assert.Empty(harness.FramesOfTypeRaw("equipment_cantrip_state"));
|
||||||
|
|
||||||
|
harness.Host.Automation.Character.ActiveEnchantments =
|
||||||
|
[new PluginActiveEnchantment(6089, 100, 8, double.PositiveInfinity)];
|
||||||
|
harness.Tick(1.0);
|
||||||
|
harness.Tick(0.3);
|
||||||
|
|
||||||
|
Assert.Single(harness.FramesOfTypeRaw("equipment_cantrip_state"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
using AcDream.Plugin.Abstractions;
|
||||||
|
using OpenAC.MosswartMassacre.Inventory;
|
||||||
|
using OpenAC.MosswartMassacre.Tests.Fakes;
|
||||||
|
|
||||||
|
namespace OpenAC.MosswartMassacre.Tests.Inventory;
|
||||||
|
|
||||||
|
public sealed class IdentifyQueueTests
|
||||||
|
{
|
||||||
|
private static (IdentifyQueue Queue, FakeAutomation Automation, List<(uint, bool)> Finished) Create()
|
||||||
|
{
|
||||||
|
var automation = new FakeAutomation { IsAvailable = true };
|
||||||
|
var queue = new IdentifyQueue(() => automation);
|
||||||
|
var finished = new List<(uint, bool)>();
|
||||||
|
queue.Finished += (id, ok) => finished.Add((id, ok));
|
||||||
|
return (queue, automation, finished);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Requests_go_out_one_at_a_time_and_the_next_waits_for_the_answer()
|
||||||
|
{
|
||||||
|
var (queue, automation, finished) = Create();
|
||||||
|
queue.Enqueue(1);
|
||||||
|
queue.Enqueue(2);
|
||||||
|
queue.Enqueue(1);
|
||||||
|
|
||||||
|
queue.Pump(0.015);
|
||||||
|
queue.Pump(0.015);
|
||||||
|
Assert.Equal([1u], automation.Objects.IdentifyRequests);
|
||||||
|
|
||||||
|
queue.OnIdentReceived(1);
|
||||||
|
queue.Pump(0.015);
|
||||||
|
Assert.Equal([1u, 2u], automation.Objects.IdentifyRequests);
|
||||||
|
Assert.Equal([(1u, true)], finished);
|
||||||
|
Assert.False(queue.IsIdle);
|
||||||
|
|
||||||
|
queue.OnIdentReceived(2);
|
||||||
|
Assert.True(queue.IsIdle);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_busy_slot_is_asked_again_on_a_later_tick()
|
||||||
|
{
|
||||||
|
var (queue, automation, _) = Create();
|
||||||
|
automation.Objects.IdentifyBusyForCalls[7] = 2;
|
||||||
|
queue.Enqueue(7);
|
||||||
|
|
||||||
|
queue.Pump(0.015);
|
||||||
|
queue.Pump(0.015);
|
||||||
|
queue.Pump(0.015);
|
||||||
|
|
||||||
|
Assert.Equal([7u, 7u, 7u], automation.Objects.IdentifyRequests);
|
||||||
|
Assert.Equal(1, queue.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_host_answer_or_abandon_finishes_a_request()
|
||||||
|
{
|
||||||
|
var (queue, automation, finished) = Create();
|
||||||
|
queue.Enqueue(3);
|
||||||
|
queue.Enqueue(4);
|
||||||
|
queue.Pump(0.015);
|
||||||
|
|
||||||
|
automation.Loot.Appraisal = new PluginAppraisalState(5, 0u, 3u);
|
||||||
|
queue.Pump(0.015);
|
||||||
|
automation.Loot.Appraisal = new PluginAppraisalState(6, 0u, 3u, LastAbandonedObjectId: 4u);
|
||||||
|
queue.Pump(0.015);
|
||||||
|
|
||||||
|
Assert.Equal([(3u, true), (4u, false)], finished);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void An_unanswered_request_is_given_up_after_the_timeout_and_a_refusal_at_once()
|
||||||
|
{
|
||||||
|
var (queue, automation, finished) = Create();
|
||||||
|
automation.Objects.IdentifyResultOverrides[8] = PluginItemCommandStatus.InvalidItem;
|
||||||
|
queue.Enqueue(8);
|
||||||
|
queue.Enqueue(9);
|
||||||
|
|
||||||
|
queue.Pump(0.015);
|
||||||
|
Assert.Equal([(8u, false)], finished);
|
||||||
|
|
||||||
|
for (int i = 0; i < 700; i++)
|
||||||
|
queue.Pump(0.015);
|
||||||
|
|
||||||
|
Assert.Equal([(8u, false), (9u, false)], finished);
|
||||||
|
Assert.True(queue.IsIdle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,255 @@
|
||||||
|
using AcDream.Plugin.Abstractions;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using OpenAC.MosswartMassacre.Inventory;
|
||||||
|
using static OpenAC.MosswartMassacre.Tests.Inventory.InventoryWorld;
|
||||||
|
|
||||||
|
namespace OpenAC.MosswartMassacre.Tests.Inventory;
|
||||||
|
|
||||||
|
public sealed class InventoryFeatureTests
|
||||||
|
{
|
||||||
|
private const string Prefix = "[Mosswart Massacre] ";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void First_login_appraises_what_needs_it_and_sends_only_when_the_answers_are_in()
|
||||||
|
{
|
||||||
|
using var harness = new PluginHarness("Mossy", arrange: host =>
|
||||||
|
{
|
||||||
|
Own(host, Tachi(appraised: false));
|
||||||
|
Own(host, Tapers());
|
||||||
|
});
|
||||||
|
LoginAndSettle(harness);
|
||||||
|
harness.Tick(0.1);
|
||||||
|
|
||||||
|
Assert.Contains(Prefix + "Requesting id information for all armor/weapon inventory. This will take a few minutes...", harness.ChatLines);
|
||||||
|
Assert.Equal([0x80000202u], harness.Host.Automation.Objects.IdentifyRequests);
|
||||||
|
harness.Tick(0.1);
|
||||||
|
Assert.Empty(harness.FramesOfTypeRaw("full_inventory"));
|
||||||
|
|
||||||
|
Own(harness.Host, Tachi(appraised: true), TachiProps(), hostIdTime: 5000);
|
||||||
|
Raise(harness.Host, 0x80000202u, PluginObjectChangeKind.IdentReceived);
|
||||||
|
harness.Tick(0.1);
|
||||||
|
|
||||||
|
JObject frame = JObject.Parse(harness.FramesOfTypeRaw("full_inventory").Single());
|
||||||
|
Assert.Equal(2, (int)frame["item_count"]!);
|
||||||
|
Assert.True((bool)frame["items"]![0]!["HasIdData"]!);
|
||||||
|
Assert.Contains(Prefix + "Requesting id information for all armor/weapon inventory completed. Log file written.", harness.ChatLines);
|
||||||
|
Assert.Contains(Prefix + "Inventory sent to MosswartOverlord", harness.ChatLines);
|
||||||
|
Assert.NotNull(harness.Host.Storage.ReadText(InventoryFeature.CacheKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_later_login_sends_at_once_and_keeps_the_saved_appraisal_of_an_unappraised_item()
|
||||||
|
{
|
||||||
|
using var harness = new PluginHarness("Mossy", arrange: host =>
|
||||||
|
Own(host, Tachi(appraised: true), TachiProps(), hostIdTime: 5000));
|
||||||
|
LoginAndSettle(harness);
|
||||||
|
harness.Tick(0.1);
|
||||||
|
harness.Tick(0.1);
|
||||||
|
Assert.Single(harness.FramesOfTypeRaw("full_inventory"));
|
||||||
|
|
||||||
|
// Relog: the session's appraisal is gone, the saved copy is not.
|
||||||
|
harness.Plugin.OnLogoff();
|
||||||
|
Own(harness.Host, Tachi(appraised: false));
|
||||||
|
harness.Frames.Clear();
|
||||||
|
harness.Host.Automation.Objects.IdentifyRequests.Clear();
|
||||||
|
LoginAndSettle(harness);
|
||||||
|
harness.Tick(0.1);
|
||||||
|
|
||||||
|
JObject frame = JObject.Parse(harness.FramesOfTypeRaw("full_inventory").Single());
|
||||||
|
JToken item = frame["items"]![0]!;
|
||||||
|
Assert.True((bool)item["HasIdData"]!);
|
||||||
|
Assert.Equal(50, (int)item["MaxDamage"]!);
|
||||||
|
Assert.Equal("Light Weapons", (string)item["EquipSkill"]!);
|
||||||
|
// Saved data present, so the original did not ask again from the dump
|
||||||
|
// (MossyInventory.cs:253); the login stream already asked once.
|
||||||
|
Assert.Equal([0x80000202u], harness.Host.Automation.Objects.IdentifyRequests);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Sendinventory_sends_at_once_when_everything_is_appraised()
|
||||||
|
{
|
||||||
|
using var harness = new PluginHarness("Mossy", arrange: host => Own(host, Tapers()));
|
||||||
|
LoginAndSettle(harness);
|
||||||
|
harness.Tick(0.1);
|
||||||
|
harness.Tick(0.1);
|
||||||
|
harness.Frames.Clear();
|
||||||
|
|
||||||
|
harness.Context.Commands.Dispatch("sendinventory");
|
||||||
|
|
||||||
|
Assert.Single(harness.FramesOfTypeRaw("full_inventory"));
|
||||||
|
Assert.Contains(Prefix + "[INV] All 1 items already identified. Sending...", harness.ChatLines);
|
||||||
|
Assert.Contains(Prefix + "[INV] Inventory upload completed", harness.ChatLines);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Sendinventory_waits_for_appraisals_and_refuses_when_streaming_is_off()
|
||||||
|
{
|
||||||
|
using var harness = new PluginHarness("Mossy", arrange: host => Own(host, Tapers()));
|
||||||
|
LoginAndSettle(harness);
|
||||||
|
harness.Tick(0.1);
|
||||||
|
harness.Tick(0.1);
|
||||||
|
harness.Frames.Clear();
|
||||||
|
Own(harness.Host, Ring());
|
||||||
|
|
||||||
|
harness.Context.Commands.Dispatch("sendinventory");
|
||||||
|
Assert.Contains(Prefix + "[INV] Scanning 2 items, requesting ID for 1. Waiting for identification...", harness.ChatLines);
|
||||||
|
harness.Tick(0.1);
|
||||||
|
Assert.Empty(harness.FramesOfTypeRaw("full_inventory"));
|
||||||
|
|
||||||
|
Own(harness.Host, Ring(), Props(ints: new() { [107] = 10, [108] = 20 }), hostIdTime: 42);
|
||||||
|
Raise(harness.Host, 0x80000404u, PluginObjectChangeKind.IdentReceived);
|
||||||
|
harness.Tick(0.1);
|
||||||
|
Assert.Single(harness.FramesOfTypeRaw("full_inventory"));
|
||||||
|
Assert.Contains(Prefix + "[INV] All items identified. Inventory upload completed.", harness.ChatLines);
|
||||||
|
|
||||||
|
harness.Context.Settings.Current.WebSocketEnabled = false;
|
||||||
|
harness.Context.Commands.Dispatch("sendinventory");
|
||||||
|
Assert.Contains(Prefix + "[INV] WebSocket streaming is disabled", harness.ChatLines);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_login_stream_is_tracked_without_adds_and_items_after_it_are_added()
|
||||||
|
{
|
||||||
|
using var harness = new PluginHarness("Mossy");
|
||||||
|
harness.Login();
|
||||||
|
InventoryFeature feature = harness.Feature<InventoryFeature>();
|
||||||
|
feature.IsBackendReady = () => true;
|
||||||
|
|
||||||
|
Own(harness.Host, Tapers());
|
||||||
|
Raise(harness.Host, 0x80000101u, PluginObjectChangeKind.Created);
|
||||||
|
Assert.False(feature.IsSettled);
|
||||||
|
for (int i = 0; i < 40 && !feature.IsSettled; i++)
|
||||||
|
harness.Tick(0.1);
|
||||||
|
|
||||||
|
Assert.Empty(harness.FramesOfTypeRaw("inventory_delta"));
|
||||||
|
Assert.Equal(1, feature.TrackedCount);
|
||||||
|
|
||||||
|
Own(harness.Host, Breastplate(), BreastplateProps(), hostIdTime: 9);
|
||||||
|
Raise(harness.Host, 0x80000303u, PluginObjectChangeKind.Created);
|
||||||
|
Assert.Equal("add", (string)JObject.Parse(harness.FramesOfTypeRaw("inventory_delta").Single())["action"]!);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Changes_are_held_until_the_jittered_flush_between_two_and_five_minutes()
|
||||||
|
{
|
||||||
|
using var harness = new PluginHarness("Mossy", arrange: host => Own(host, Tapers()));
|
||||||
|
InventoryFeature feature = LoginAndSettle(harness);
|
||||||
|
harness.Frames.Clear();
|
||||||
|
|
||||||
|
Own(harness.Host, Tapers(60));
|
||||||
|
Raise(harness.Host, 0x80000101u, PluginObjectChangeKind.Updated);
|
||||||
|
Raise(harness.Host, 0x80000101u, PluginObjectChangeKind.Updated);
|
||||||
|
Assert.Equal(1, feature.DirtyCount);
|
||||||
|
|
||||||
|
for (int i = 0; i < 1190; i++)
|
||||||
|
harness.Tick(0.1);
|
||||||
|
Assert.Empty(harness.FramesOfTypeRaw("inventory_delta"));
|
||||||
|
|
||||||
|
for (int i = 0; i < 1820 && harness.FramesOfTypeRaw("inventory_delta").Count == 0; i++)
|
||||||
|
harness.Tick(0.1);
|
||||||
|
|
||||||
|
JObject update = JObject.Parse(harness.FramesOfTypeRaw("inventory_delta").Single());
|
||||||
|
Assert.Equal("update", (string)update["action"]!);
|
||||||
|
Assert.Equal(60, (int)update["item"]!["IntValues"]!["218103814"]!);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_flush_interval_is_two_minutes_plus_up_to_three()
|
||||||
|
{
|
||||||
|
using var harness = new PluginHarness("Mossy");
|
||||||
|
InventoryFeature feature = harness.Feature<InventoryFeature>();
|
||||||
|
|
||||||
|
int[] samples = Enumerable.Range(0, 2000).Select(_ => feature.NextFlushIntervalMs()).ToArray();
|
||||||
|
|
||||||
|
Assert.All(samples, ms => Assert.InRange(ms, 120000, 300000));
|
||||||
|
Assert.True(samples.Max() - samples.Min() > 150000);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void An_item_moved_out_of_the_inventory_is_removed_and_one_in_a_side_pack_is_added()
|
||||||
|
{
|
||||||
|
const uint pack = 0x80000900u;
|
||||||
|
using var harness = new PluginHarness("Mossy", arrange: host =>
|
||||||
|
{
|
||||||
|
Own(host, Tapers());
|
||||||
|
host.Automation.Objects.Replace(new PluginWorldObject(pack, 1u, "Pack", PluginObjectClass.Container, 0x200u, Me, 0u));
|
||||||
|
});
|
||||||
|
LoginAndSettle(harness);
|
||||||
|
harness.Frames.Clear();
|
||||||
|
|
||||||
|
Own(harness.Host, Ring() with { ContainerObjectId = pack, WielderObjectId = 0u, EquippedLocation = 0u });
|
||||||
|
Raise(harness.Host, 0x80000404u, PluginObjectChangeKind.Moved);
|
||||||
|
Own(harness.Host, Tapers() with { ContainerObjectId = 0x7000AAAAu });
|
||||||
|
Raise(harness.Host, 0x80000101u, PluginObjectChangeKind.Moved);
|
||||||
|
|
||||||
|
string[] actions = harness.FramesOfTypeRaw("inventory_delta")
|
||||||
|
.Select(f => (string)JObject.Parse(f)["action"]!).ToArray();
|
||||||
|
Assert.Equal(["add", "remove"], actions);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_relog_starts_tracking_afresh()
|
||||||
|
{
|
||||||
|
using var harness = new PluginHarness("Mossy", arrange: host => Own(host, Tapers()));
|
||||||
|
InventoryFeature feature = LoginAndSettle(harness);
|
||||||
|
Own(harness.Host, Tapers(60));
|
||||||
|
Raise(harness.Host, 0x80000101u, PluginObjectChangeKind.Updated);
|
||||||
|
Assert.Equal(1, feature.DirtyCount);
|
||||||
|
|
||||||
|
harness.Plugin.OnLogoff();
|
||||||
|
Assert.Equal(0, feature.DirtyCount);
|
||||||
|
Assert.Equal(0, feature.TrackedCount);
|
||||||
|
|
||||||
|
harness.Host.Automation.Character.Name = "Horan";
|
||||||
|
harness.Frames.Clear();
|
||||||
|
LoginAndSettle(harness);
|
||||||
|
Raise(harness.Host, 0x80000101u, PluginObjectChangeKind.Updated);
|
||||||
|
|
||||||
|
Assert.Empty(harness.FramesOfTypeRaw("inventory_delta"));
|
||||||
|
Assert.Equal(1, feature.DirtyCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Logoff_sends_the_inventory_while_the_socket_is_still_up()
|
||||||
|
{
|
||||||
|
using var harness = new PluginHarness("Mossy", arrange: host => Own(host, Tapers()));
|
||||||
|
LoginAndSettle(harness);
|
||||||
|
harness.Tick(0.1);
|
||||||
|
harness.Tick(0.1);
|
||||||
|
harness.Frames.Clear();
|
||||||
|
|
||||||
|
harness.Host.Events.RaiseLogoff();
|
||||||
|
|
||||||
|
Assert.Single(harness.FramesOfTypeRaw("full_inventory"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_taper_count_follows_the_stacks_in_the_packs()
|
||||||
|
{
|
||||||
|
using var harness = new PluginHarness("Mossy", arrange: host => Own(host, Tapers()));
|
||||||
|
InventoryFeature feature = LoginAndSettle(harness);
|
||||||
|
Assert.Equal(57, feature.PrismaticTaperCount);
|
||||||
|
|
||||||
|
Own(harness.Host, Tapers(12));
|
||||||
|
Raise(harness.Host, 0x80000101u, PluginObjectChangeKind.Updated);
|
||||||
|
Assert.Equal(12, feature.PrismaticTaperCount);
|
||||||
|
|
||||||
|
Forget(harness.Host, 0x80000101u);
|
||||||
|
harness.Host.Events.RaiseObjectChanged(new PluginObjectChange(0x80000101u, PluginObjectChangeKind.Released));
|
||||||
|
Assert.Equal(0, feature.PrismaticTaperCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Inventory_log_off_sends_no_login_inventory()
|
||||||
|
{
|
||||||
|
using var harness = new PluginHarness("Mossy", arrange: host => Own(host, Tapers()));
|
||||||
|
harness.Login();
|
||||||
|
harness.Context.Settings.Current.InventoryLog = false;
|
||||||
|
InventoryFeature feature = harness.Feature<InventoryFeature>();
|
||||||
|
feature.IsBackendReady = () => true;
|
||||||
|
for (int i = 0; i < 40; i++)
|
||||||
|
harness.Tick(0.1);
|
||||||
|
|
||||||
|
Assert.Empty(harness.FramesOfTypeRaw("full_inventory"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,155 @@
|
||||||
|
using AcDream.Plugin.Abstractions;
|
||||||
|
using OpenAC.MosswartMassacre.Inventory;
|
||||||
|
using static OpenAC.MosswartMassacre.Tests.Inventory.InventoryWorld;
|
||||||
|
|
||||||
|
namespace OpenAC.MosswartMassacre.Tests.Inventory;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Byte-for-byte frames, derived by hand from the original plugin's code.
|
||||||
|
/// Shape sources (original plugin, head 48cf13b):
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>Item record: <c>Shared/MyWorldObject.cs:16-29</c> (fields, in order) and
|
||||||
|
/// <c>:118-315</c> (getters, in order; doubles stay doubles so -1 is written -1.0).</item>
|
||||||
|
/// <item>Record contents: <c>Shared/MyWorldObjectCreator.cs:20-39</c> — the framework's
|
||||||
|
/// key tables, spells, active spells, id, appraisal time, class, icon, container.</item>
|
||||||
|
/// <item>Which framework keys exist and what they hold: the original's own item
|
||||||
|
/// dumps (3058 items), see <see cref="DecalItemBuilder"/>.</item>
|
||||||
|
/// <item>Envelopes: <c>MosswartMassacre/WebSocket.cs:339-380</c> (full_inventory,
|
||||||
|
/// inventory_delta add/update and remove) and <c>:411-423</c> (equipment_cantrip_state);
|
||||||
|
/// the per-item mana entry <c>EquipmentCantripStateTracker.cs:222-229</c>.</item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class InventoryGoldenTests
|
||||||
|
{
|
||||||
|
// The harness clock, 2026-09-25 08:30:15.123 UTC, in Unix seconds: the
|
||||||
|
// original stamped an appraisal with the wall clock when it arrived.
|
||||||
|
private const string IdStamp = "1790325015";
|
||||||
|
|
||||||
|
// Getters from ElementalDmgBonus onward for an item with none of their keys
|
||||||
|
// except Value/Burden (MyWorldObject.cs:163-315).
|
||||||
|
private const string TaperRecord =
|
||||||
|
"""{"HasIdData":false,"Id":-2147483391,"LastIdTime":0,"ObjectClass":12,"Icon":11658,"ContainerId":1342177281,"BoolValues":{},"DoubleValues":{},"IntValues":{"5":57,"19":570,"218103808":20631,"218103809":11658,"218103810":1342177281,"218103814":57,"218103815":100,"218103834":4096,"218103835":18,"218103843":1},"StringValues":{"1":"Prismatic Taper","184549376":"Prismatic Tapers"},"ActiveSpells":[],"Spells":[],"Material":null,"Name":"Prismatic Taper","EquipSkill":null,"Mastery":null,"ItemSet":null,"ArmorLevel":-1,"Imbue":null,"Tinks":-1,"MaxDamage":-1,"ElementalDmgBonus":-1,"Variance":-1.0,"DamageBonus":-1.0,"ElementalDamageVersusMonsters":-1.0,"AttackBonus":-1.0,"MeleeDefenseBonus":-1.0,"MagicDBonus":-1.0,"MissileDBonus":-1.0,"ManaCBonus":-1.0,"WieldLevel":-1,"SkillLevel":-1,"LoreRequirement":-1,"SalvageWorkmanship":-1.0,"Workmanship":-1,"Value":570,"Burden":57,"DamRating":-1,"DamResistRating":-1,"CritRating":-1,"CritResistRating":-1,"CritDamRating":-1,"CritDamResistRating":-1,"HealBoostRating":-1,"VitalityRating":-1,"TotalRating":-1,"CalcedStartingArmorLevel":-1,"CalcedBuffedTinkedDoT":-1.0,"CalcedBuffedMissileDamage":-1.0,"BuffedElementalDamageVersusMonsters":-1.0,"BuffedAttackBonus":-1.0,"BuffedMeleeDefenseBonus":-1.0,"BuffedManaCBonus":-1.0}""";
|
||||||
|
|
||||||
|
// Armor profile -> 167772160..166 (slash, pierce, bludgeon, acid, lightning, fire,
|
||||||
|
// cold), floats widened; spell book split on the 0x80000000 bit; ItemSet 5 from
|
||||||
|
// Dictionaries.AttributeSetInfo; CalcedStartingArmorLevel (MyWorldObject.cs:232-265):
|
||||||
|
// 600 - 200 (no material) - (240 active Incantation of Impenetrability - 20 Minor
|
||||||
|
// Impenetrability bonus) = 180.
|
||||||
|
private const string BreastplateRecord =
|
||||||
|
"""{"HasIdData":true,"Id":-2147482877,"LastIdTime":""" + IdStamp + ""","ObjectClass":2,"Icon":4660,"ContainerId":1342177281,"BoolValues":{"69":true},"DoubleValues":{"167772160":1.2000000476837158,"167772161":1.0,"167772162":0.800000011920929,"167772163":0.20000000298023224,"167772164":1.5,"167772165":0.4000000059604645,"167772166":0.6000000238418579},"IntValues":{"5":800,"19":5000,"28":600,"265":5,"218103808":6044,"218103809":4660,"218103810":1342177281,"218103821":1024,"218103822":512,"218103824":1,"218103834":2,"218103835":18,"218103838":2,"218103843":1,"218103848":2},"StringValues":{"1":"Celdon Breastplate"},"ActiveSpells":[4407,1483],"Spells":[1486,2604],"Material":null,"Name":"Celdon Breastplate","EquipSkill":null,"Mastery":null,"ItemSet":"Noble Relic Set","ArmorLevel":600,"Imbue":null,"Tinks":-1,"MaxDamage":-1,"ElementalDmgBonus":-1,"Variance":-1.0,"DamageBonus":-1.0,"ElementalDamageVersusMonsters":-1.0,"AttackBonus":-1.0,"MeleeDefenseBonus":-1.0,"MagicDBonus":-1.0,"MissileDBonus":-1.0,"ManaCBonus":-1.0,"WieldLevel":-1,"SkillLevel":-1,"LoreRequirement":-1,"SalvageWorkmanship":-1.0,"Workmanship":-1,"Value":5000,"Burden":800,"DamRating":-1,"DamResistRating":-1,"CritRating":-1,"CritResistRating":-1,"CritDamRating":-1,"CritDamResistRating":-1,"HealBoostRating":-1,"VitalityRating":-1,"TotalRating":-1,"CalcedStartingArmorLevel":180,"CalcedBuffedTinkedDoT":-1.0,"CalcedBuffedMissileDamage":-1.0,"BuffedElementalDamageVersusMonsters":-1.0,"BuffedAttackBonus":-1.0,"BuffedMeleeDefenseBonus":-1.0,"BuffedManaCBonus":-1.0}""";
|
||||||
|
|
||||||
|
// Wielded: the container key and ContainerId are the wielder. The second header
|
||||||
|
// (public flags 0x04000000) brings the underlay key, here 0.
|
||||||
|
private const string TachiUnappraisedRecord =
|
||||||
|
"""{"HasIdData":false,"Id":-2147483134,"LastIdTime":0,"ObjectClass":1,"Icon":13162,"ContainerId":1342177281,"BoolValues":{},"DoubleValues":{"167772169":8.0},"IntValues":{"5":270,"10":1048576,"19":12000,"131":64,"218103808":30310,"218103809":13162,"218103810":1342177281,"218103822":1048576,"218103823":1,"218103824":1,"218103826":16,"218103834":1,"218103835":67108882,"218103843":6291460,"218103849":6958,"218103850":0},"StringValues":{"1":"Fire Tachi"},"ActiveSpells":[],"Spells":[],"Material":"Steel","Name":"Fire Tachi","EquipSkill":null,"Mastery":null,"ItemSet":null,"ArmorLevel":-1,"Imbue":null,"Tinks":-1,"MaxDamage":-1,"ElementalDmgBonus":-1,"Variance":-1.0,"DamageBonus":-1.0,"ElementalDamageVersusMonsters":-1.0,"AttackBonus":-1.0,"MeleeDefenseBonus":-1.0,"MagicDBonus":-1.0,"MissileDBonus":-1.0,"ManaCBonus":-1.0,"WieldLevel":-1,"SkillLevel":-1,"LoreRequirement":-1,"SalvageWorkmanship":8.0,"Workmanship":-1,"Value":12000,"Burden":270,"DamRating":-1,"DamResistRating":-1,"CritRating":-1,"CritResistRating":-1,"CritDamRating":-1,"CritDamResistRating":-1,"HealBoostRating":-1,"VitalityRating":-1,"TotalRating":-1,"CalcedStartingArmorLevel":-1,"CalcedBuffedTinkedDoT":-1.0,"CalcedBuffedMissileDamage":-1.0,"BuffedElementalDamageVersusMonsters":-1.0,"BuffedAttackBonus":-1.0,"BuffedMeleeDefenseBonus":-1.0,"BuffedManaCBonus":-1.0}""";
|
||||||
|
|
||||||
|
// Weapon profile -> 218103839..842 and 167772171..174. Getters: EquipSkill 45 ->
|
||||||
|
// "Light Weapons", Mastery 2 -> "Sword", Imbue 1 -> "CS", SkillLevel (158 != 7) 370,
|
||||||
|
// TotalRating 2. CalcedBuffedTinkedDoT (MyWorldObject.cs:270-302): max damage
|
||||||
|
// 50 - 24 (active Incantation of Blood Drinker) + 2 (Minor Blood Thirst) = 28, seven
|
||||||
|
// tinks left, giving 55.431200000000004. BuffedAttackBonus: 1.2000000476837158 + .03
|
||||||
|
// (Minor Heart Thirst) = 1.2300000476837158.
|
||||||
|
private const string TachiAppraisedRecord =
|
||||||
|
"""{"HasIdData":true,"Id":-2147483134,"LastIdTime":""" + IdStamp + ""","ObjectClass":1,"Icon":13162,"ContainerId":1342177281,"BoolValues":{},"DoubleValues":{"5":-0.05,"29":1.15,"167772169":8.0,"167772171":0.5,"167772172":1.2000000476837158,"167772173":1.0,"167772174":1.0},"IntValues":{"5":270,"10":1048576,"19":12000,"105":8,"107":1500,"108":2000,"109":300,"131":64,"158":2,"159":45,"160":370,"171":3,"179":1,"353":2,"370":2,"218103808":30310,"218103809":13162,"218103810":1342177281,"218103822":1048576,"218103823":1,"218103824":1,"218103826":16,"218103834":1,"218103835":67108882,"218103838":2,"218103839":30,"218103840":45,"218103841":16,"218103842":50,"218103843":6291460,"218103848":1,"218103849":6958,"218103850":0},"StringValues":{"1":"Fire Tachi","16":"Fire Tachi of Blood Drinker"},"ActiveSpells":[5183],"Spells":[2598,2603],"Material":"Steel","Name":"Fire Tachi","EquipSkill":"Light Weapons","Mastery":"Sword","ItemSet":null,"ArmorLevel":-1,"Imbue":"CS","Tinks":3,"MaxDamage":50,"ElementalDmgBonus":-1,"Variance":0.5,"DamageBonus":1.0,"ElementalDamageVersusMonsters":-1.0,"AttackBonus":1.2000000476837158,"MeleeDefenseBonus":1.15,"MagicDBonus":-1.0,"MissileDBonus":-1.0,"ManaCBonus":-1.0,"WieldLevel":-1,"SkillLevel":370,"LoreRequirement":300,"SalvageWorkmanship":8.0,"Workmanship":8,"Value":12000,"Burden":270,"DamRating":2,"DamResistRating":-1,"CritRating":-1,"CritResistRating":-1,"CritDamRating":-1,"CritDamResistRating":-1,"HealBoostRating":-1,"VitalityRating":-1,"TotalRating":2,"CalcedStartingArmorLevel":-1,"CalcedBuffedTinkedDoT":55.431200000000004,"CalcedBuffedMissileDamage":-1.0,"BuffedElementalDamageVersusMonsters":-1.0,"BuffedAttackBonus":1.2300000476837158,"BuffedMeleeDefenseBonus":1.15,"BuffedManaCBonus":-1.0}""";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Full_inventory_frame_after_the_first_login()
|
||||||
|
{
|
||||||
|
using var harness = new PluginHarness("Mossy", arrange: host =>
|
||||||
|
{
|
||||||
|
Own(host, Tapers());
|
||||||
|
Own(host, Breastplate(), BreastplateProps(), hostIdTime: 777);
|
||||||
|
});
|
||||||
|
|
||||||
|
LoginAndSettle(harness);
|
||||||
|
harness.Tick(0.1); // the login upload: no saved copy, so appraise and wait
|
||||||
|
harness.Tick(0.1); // nothing needed appraising: send
|
||||||
|
|
||||||
|
// WebSocket.cs:341-348: type, timestamp, character_name, item_count, items.
|
||||||
|
string expected =
|
||||||
|
"""{"type":"full_inventory","timestamp":"2026-09-25T08:30:15.1230000Z","character_name":"Mossy","item_count":2,"items":["""
|
||||||
|
+ TaperRecord + "," + BreastplateRecord + "]}";
|
||||||
|
Assert.Equal([expected], harness.FramesOfTypeRaw("full_inventory"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Inventory_delta_add_then_update_after_appraisal()
|
||||||
|
{
|
||||||
|
using var harness = new PluginHarness("Mossy");
|
||||||
|
InventoryFeature feature = LoginAndSettle(harness);
|
||||||
|
harness.Tick(0.1);
|
||||||
|
harness.Tick(0.1);
|
||||||
|
harness.Frames.Clear();
|
||||||
|
|
||||||
|
Own(harness.Host, Tachi(appraised: false));
|
||||||
|
Raise(harness.Host, 0x80000202u, PluginObjectChangeKind.Created);
|
||||||
|
|
||||||
|
// WebSocket.cs:356-363: type, timestamp, character_name, action, item.
|
||||||
|
Assert.Equal(
|
||||||
|
["""{"type":"inventory_delta","timestamp":"2026-09-25T08:30:15.1230000Z","character_name":"Mossy","action":"add","item":""" + TachiUnappraisedRecord + "}"],
|
||||||
|
harness.FramesOfTypeRaw("inventory_delta"));
|
||||||
|
|
||||||
|
harness.Tick(0.015);
|
||||||
|
Assert.Contains(0x80000202u, harness.Host.Automation.Objects.IdentifyRequests);
|
||||||
|
|
||||||
|
Own(harness.Host, Tachi(appraised: true), TachiProps(), hostIdTime: 5000);
|
||||||
|
Raise(harness.Host, 0x80000202u, PluginObjectChangeKind.IdentReceived);
|
||||||
|
harness.Frames.Clear();
|
||||||
|
feature.FlushDirtyItems();
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
["""{"type":"inventory_delta","timestamp":"2026-09-25T08:30:15.1230000Z","character_name":"Mossy","action":"update","item":""" + TachiAppraisedRecord + "}"],
|
||||||
|
harness.FramesOfTypeRaw("inventory_delta"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Inventory_delta_remove_carries_only_the_id()
|
||||||
|
{
|
||||||
|
using var harness = new PluginHarness("Mossy", arrange: host => Own(host, Tapers()));
|
||||||
|
LoginAndSettle(harness);
|
||||||
|
harness.Frames.Clear();
|
||||||
|
|
||||||
|
Forget(harness.Host, 0x80000101u);
|
||||||
|
harness.Host.Events.RaiseObjectChanged(new PluginObjectChange(0x80000101u, PluginObjectChangeKind.Released));
|
||||||
|
|
||||||
|
// WebSocket.cs:370-377: the id is the framework's signed 32-bit id.
|
||||||
|
Assert.Equal(
|
||||||
|
["""{"type":"inventory_delta","timestamp":"2026-09-25T08:30:15.1230000Z","character_name":"Mossy","action":"remove","item_id":-2147483391}"""],
|
||||||
|
harness.FramesOfTypeRaw("inventory_delta"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Equipment_cantrip_state_for_an_active_weapon_and_an_unappraised_ring()
|
||||||
|
{
|
||||||
|
using var harness = new PluginHarness("Mossy", arrange: host =>
|
||||||
|
{
|
||||||
|
Own(host, Tachi(appraised: true), TachiProps(), hostIdTime: 5000);
|
||||||
|
Own(host, Ring());
|
||||||
|
host.Automation.Spells.All =
|
||||||
|
[
|
||||||
|
Spell(2598, family: 100, difficulty: 150),
|
||||||
|
Spell(2603, family: 101, difficulty: 150),
|
||||||
|
Spell(5183, family: 200, difficulty: 400),
|
||||||
|
Spell(6089, family: 100, difficulty: 400),
|
||||||
|
Spell(6094, family: 101, difficulty: 400),
|
||||||
|
];
|
||||||
|
host.Automation.Character.ActiveEnchantments =
|
||||||
|
[
|
||||||
|
new PluginActiveEnchantment(6089, 100, 8, double.PositiveInfinity),
|
||||||
|
new PluginActiveEnchantment(6094, 101, 8, double.PositiveInfinity),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
harness.Login();
|
||||||
|
harness.Frames.Clear();
|
||||||
|
|
||||||
|
// Mana was last reported at login; 65 s later three points have burned at
|
||||||
|
// one per ceil(-0.2 / -0.05) * 5 = 20 s (EquipmentCantripStateTracker.cs:296-345).
|
||||||
|
harness.Clock.Advance(TimeSpan.FromSeconds(65));
|
||||||
|
harness.Feature<EquipmentCantripStateFeature>().SendSnapshot();
|
||||||
|
|
||||||
|
// WebSocket.cs:413-419 envelope; items from EquipmentCantripStateTracker.cs:222-229.
|
||||||
|
Assert.Equal(
|
||||||
|
["""{"type":"equipment_cantrip_state","timestamp":"2026-09-25T08:31:20.1230000Z","character_name":"Mossy","items":[{"item_id":-2147483134,"state":"active","current_mana":1497,"max_mana":2000,"mana_time_remaining_seconds":29940.0},{"item_id":-2147482620,"state":"unknown","current_mana":null,"max_mana":null,"mana_time_remaining_seconds":null}]}"""],
|
||||||
|
harness.FramesOfTypeRaw("equipment_cantrip_state"));
|
||||||
|
}
|
||||||
|
}
|
||||||
205
tests/OpenAC.MosswartMassacre.Tests/Inventory/InventoryWorld.cs
Normal file
205
tests/OpenAC.MosswartMassacre.Tests/Inventory/InventoryWorld.cs
Normal file
|
|
@ -0,0 +1,205 @@
|
||||||
|
using AcDream.Plugin.Abstractions;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using OpenAC.MosswartMassacre.Inventory;
|
||||||
|
using OpenAC.MosswartMassacre.Tests.Fakes;
|
||||||
|
|
||||||
|
namespace OpenAC.MosswartMassacre.Tests.Inventory;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Puts items into the fake host the way the real one holds them: the item
|
||||||
|
/// in the owned list, its world object in the object table, its property
|
||||||
|
/// tables when it has any.
|
||||||
|
/// </summary>
|
||||||
|
internal static class InventoryWorld
|
||||||
|
{
|
||||||
|
/// <summary>The harness character's id.</summary>
|
||||||
|
public const uint Me = 0x50000001;
|
||||||
|
|
||||||
|
public static PluginItemProperties Props(
|
||||||
|
Dictionary<uint, int>? ints = null,
|
||||||
|
Dictionary<uint, double>? floats = null,
|
||||||
|
Dictionary<uint, bool>? bools = null,
|
||||||
|
Dictionary<uint, string>? strings = null,
|
||||||
|
PluginWeaponProfile? weapon = null,
|
||||||
|
PluginArmorProfile? armor = null)
|
||||||
|
=> new(
|
||||||
|
ints ?? [],
|
||||||
|
new Dictionary<uint, long>(),
|
||||||
|
bools ?? [],
|
||||||
|
floats ?? [],
|
||||||
|
strings ?? [],
|
||||||
|
new Dictionary<uint, uint>(),
|
||||||
|
new Dictionary<uint, uint>())
|
||||||
|
{
|
||||||
|
WeaponProfile = weapon,
|
||||||
|
ArmorProfile = armor,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds or replaces an owned item. <paramref name="hostIdTime"/> non-zero
|
||||||
|
/// with properties means appraised.
|
||||||
|
/// </summary>
|
||||||
|
public static void Own(
|
||||||
|
FakeHost host,
|
||||||
|
PluginInventoryItem item,
|
||||||
|
PluginItemProperties? properties = null,
|
||||||
|
int hostIdTime = 0)
|
||||||
|
{
|
||||||
|
List<PluginInventoryItem> owned = host.Automation.Items.Owned;
|
||||||
|
int index = owned.FindIndex(existing => existing.ObjectId == item.ObjectId);
|
||||||
|
if (index >= 0)
|
||||||
|
owned[index] = item;
|
||||||
|
else
|
||||||
|
owned.Add(item);
|
||||||
|
|
||||||
|
if (properties is { } props)
|
||||||
|
host.Automation.Items.Properties[item.ObjectId] = props;
|
||||||
|
else
|
||||||
|
host.Automation.Items.Properties.Remove(item.ObjectId);
|
||||||
|
|
||||||
|
host.Automation.Objects.Replace(World(item, properties is not null, hostIdTime));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Takes an item out of the owned list and the object table.</summary>
|
||||||
|
public static void Forget(FakeHost host, uint objectId)
|
||||||
|
{
|
||||||
|
host.Automation.Items.Owned.RemoveAll(item => item.ObjectId == objectId);
|
||||||
|
host.Automation.Items.Properties.Remove(objectId);
|
||||||
|
host.Automation.Objects.Objects.RemoveAll(o => o.ObjectId == objectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static PluginWorldObject World(PluginInventoryItem item, bool hasProperties, int hostIdTime)
|
||||||
|
=> new(
|
||||||
|
item.ObjectId,
|
||||||
|
item.WeenieClassId,
|
||||||
|
item.Name,
|
||||||
|
item.ObjectClass,
|
||||||
|
item.ItemType,
|
||||||
|
item.ContainerObjectId,
|
||||||
|
item.WielderObjectId)
|
||||||
|
{
|
||||||
|
IsOwned = true,
|
||||||
|
HasAppraisalData = hasProperties,
|
||||||
|
LastIdTime = hostIdTime,
|
||||||
|
SpellIds = item.AppraisedSpellIds,
|
||||||
|
IconId = item.IconId,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>A change as the host raises it, with the object's current snapshot.</summary>
|
||||||
|
public static PluginObjectChange Change(FakeHost host, uint objectId, PluginObjectChangeKind kind)
|
||||||
|
=> new(objectId, kind)
|
||||||
|
{
|
||||||
|
Current = host.Automation.Objects.TryGet(objectId, out PluginWorldObject current)
|
||||||
|
? current
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
public static void Raise(FakeHost host, uint objectId, PluginObjectChangeKind kind)
|
||||||
|
=> host.Events.RaiseObjectChanged(Change(host, objectId, kind));
|
||||||
|
|
||||||
|
/// <summary>Logs in and runs the ticks it takes the inventory to settle, without moving the clock.</summary>
|
||||||
|
public static InventoryFeature LoginAndSettle(PluginHarness harness)
|
||||||
|
{
|
||||||
|
harness.Login();
|
||||||
|
InventoryFeature feature = harness.Feature<InventoryFeature>();
|
||||||
|
feature.IsBackendReady = () => true;
|
||||||
|
for (int i = 0; i < 40 && !feature.IsSettled; i++)
|
||||||
|
harness.Tick(0.1);
|
||||||
|
Assert.True(feature.IsSettled);
|
||||||
|
return feature;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A stack of 57 Prismatic Tapers in the main pack, never appraised.</summary>
|
||||||
|
public static PluginInventoryItem Tapers(int stack = 57) =>
|
||||||
|
new(
|
||||||
|
0x80000101u, 20631u, "Prismatic Taper", 0x1000u, Me, 0u, 0u, 0u,
|
||||||
|
1u, 0u, 18u, stack, 0, 0, 0u, 0, 0, 0u, false, 0d, 0, 0, 0, 0d, 0, 0, 0)
|
||||||
|
{
|
||||||
|
ObjectClass = PluginObjectClass.SpellComponent,
|
||||||
|
IconId = 0x06002D8Au,
|
||||||
|
MaximumStackSize = 100,
|
||||||
|
ContainerSlot = 0,
|
||||||
|
Burden = stack,
|
||||||
|
Value = stack * 10,
|
||||||
|
PluralName = "Prismatic Tapers",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>A breastplate in the main pack, appraised, with two carried and two running spells.</summary>
|
||||||
|
public static PluginInventoryItem Breastplate() =>
|
||||||
|
new(
|
||||||
|
0x80000303u, 6044u, "Celdon Breastplate", 0x2u, Me, 0u, 512u, 0u,
|
||||||
|
1u, 0u, 18u, 1, 0, 0, 0u, 0, 0, 0u, false, 0d, 0, 0, 0, 0d, 0, 0, 0)
|
||||||
|
{
|
||||||
|
ObjectClass = PluginObjectClass.Armor,
|
||||||
|
IconId = 0x06001234u,
|
||||||
|
ContainerSlot = 3,
|
||||||
|
Burden = 800,
|
||||||
|
Value = 5000,
|
||||||
|
CoverageMask = 1024u,
|
||||||
|
Effects = 1u,
|
||||||
|
AppraisedSpellIds = [1486u, 2604u, 0x80000000u | 4407u, 0x80000000u | 1483u],
|
||||||
|
};
|
||||||
|
|
||||||
|
public static PluginItemProperties BreastplateProps() =>
|
||||||
|
Props(
|
||||||
|
ints: new() { [28] = 600, [265] = 5 },
|
||||||
|
bools: new() { [69] = true },
|
||||||
|
armor: new PluginArmorProfile(600, 1.2f, 1.0f, 0.8f, 0.6f, 0.4f, 0.2f, 0f, 1.5f));
|
||||||
|
|
||||||
|
/// <summary>A wielded sword, before (no spells) or after appraisal.</summary>
|
||||||
|
public static PluginInventoryItem Tachi(bool appraised) =>
|
||||||
|
new(
|
||||||
|
0x80000202u, 30310u, "Fire Tachi", 0x1u, 0u, Me, 1048576u, 1048576u,
|
||||||
|
6291460u, 16u, 0x04000012u, 1, 0, 0, 0u, 0, 0, 0u, false, 0d, 0, 0, 0, 0d, 0, 0, 0)
|
||||||
|
{
|
||||||
|
ObjectClass = PluginObjectClass.MeleeWeapon,
|
||||||
|
IconId = 0x0600336Au,
|
||||||
|
IconOverlayId = 0x06001B2Eu,
|
||||||
|
IconUnderlayId = 0u,
|
||||||
|
ContainerSlot = -1,
|
||||||
|
CombatUse = 1,
|
||||||
|
Burden = 270,
|
||||||
|
Value = 12000,
|
||||||
|
MaterialType = 64u,
|
||||||
|
Workmanship = 8f,
|
||||||
|
Effects = 1u,
|
||||||
|
AppraisedSpellIds = appraised ? [2598u, 2603u, 0x80000000u | 5183u] : [],
|
||||||
|
};
|
||||||
|
|
||||||
|
public static PluginItemProperties TachiProps() =>
|
||||||
|
Props(
|
||||||
|
ints: new()
|
||||||
|
{
|
||||||
|
[105] = 8, [107] = 1500, [108] = 2000, [109] = 300, [158] = 2, [159] = 45,
|
||||||
|
[160] = 370, [171] = 3, [179] = 1, [353] = 2, [370] = 2,
|
||||||
|
},
|
||||||
|
floats: new() { [5] = -0.05, [29] = 1.15 },
|
||||||
|
strings: new() { [16] = "Fire Tachi of Blood Drinker" },
|
||||||
|
weapon: new PluginWeaponProfile(16, 30, 45u, 50, 0.5, 1.0, 1.5, 1.0, 1.2000000476837158, 0));
|
||||||
|
|
||||||
|
/// <summary>A ring the character wears, never appraised.</summary>
|
||||||
|
public static PluginInventoryItem Ring() =>
|
||||||
|
new(
|
||||||
|
0x80000404u, 2575u, "Gold Ring", 0x8u, 0u, Me, 0xC0000u, 0x40000u,
|
||||||
|
1u, 0u, 18u, 1, 0, 0, 0u, 0, 0, 0u, false, 0d, 0, 0, 0, 0d, 0, 0, 0)
|
||||||
|
{
|
||||||
|
ObjectClass = PluginObjectClass.Jewelry,
|
||||||
|
IconId = 0x06000F6Eu,
|
||||||
|
ContainerSlot = -1,
|
||||||
|
Burden = 30,
|
||||||
|
Value = 400,
|
||||||
|
};
|
||||||
|
|
||||||
|
public static PluginSpellInfo Spell(uint id, uint family, int difficulty, bool debuff = false)
|
||||||
|
=> new(id, $"Spell {id}", family, 1, difficulty, 10, -1f, 3u, string.Empty, false, true)
|
||||||
|
{
|
||||||
|
IsDebuff = debuff,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class InventoryHarnessExtensions
|
||||||
|
{
|
||||||
|
/// <summary>The raw frames of one type, in the order they were sent.</summary>
|
||||||
|
public static List<string> FramesOfTypeRaw(this PluginHarness harness, string type) =>
|
||||||
|
harness.Frames.Where(frame => (string?)JObject.Parse(frame)["type"] == type).ToList();
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue