openac-mosswartmassacre/tests/OpenAC.MosswartMassacre.Tests/Inventory/InventoryWorld.cs
Erik 7a6b0a7f9d 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>
2026-09-25 12:39:55 +02:00

205 lines
7.8 KiB
C#

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();
}