Merge slice/inventory: full_inventory, inventory_delta, equipment_cantrip_state

Resolved FeatureCatalog; telemetry now reads the taper count from the
inventory feature. The logoff inventory is sent from the feature's own
OnLogoff instead of a second host-logoff subscription whose correctness
depended on subscription order: the root logs features off in reverse, the
socket last, so the frame still leaves.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-25 12:42:41 +02:00
commit 82a518dcfb
16 changed files with 3439 additions and 0 deletions

View file

@ -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 };
}

View file

@ -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"));
}
}

View file

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

View file

@ -0,0 +1,273 @@
using AcDream.Plugin.Abstractions;
using Newtonsoft.Json.Linq;
using OpenAC.MosswartMassacre.Inventory;
using static OpenAC.MosswartMassacre.Tests.Inventory.InventoryWorld;
namespace OpenAC.MosswartMassacre.Tests.Inventory;
public sealed class InventoryFeatureTests
{
private const string Prefix = "[Mosswart Massacre] ";
[Fact]
public void First_login_appraises_what_needs_it_and_sends_only_when_the_answers_are_in()
{
using var harness = new PluginHarness("Mossy", arrange: host =>
{
Own(host, Tachi(appraised: false));
Own(host, Tapers());
});
LoginAndSettle(harness);
harness.Tick(0.1);
Assert.Contains(Prefix + "Requesting id information for all armor/weapon inventory. This will take a few minutes...", harness.ChatLines);
Assert.Equal([0x80000202u], harness.Host.Automation.Objects.IdentifyRequests);
harness.Tick(0.1);
Assert.Empty(harness.FramesOfTypeRaw("full_inventory"));
Own(harness.Host, Tachi(appraised: true), TachiProps(), hostIdTime: 5000);
Raise(harness.Host, 0x80000202u, PluginObjectChangeKind.IdentReceived);
harness.Tick(0.1);
JObject frame = JObject.Parse(harness.FramesOfTypeRaw("full_inventory").Single());
Assert.Equal(2, (int)frame["item_count"]!);
Assert.True((bool)frame["items"]![0]!["HasIdData"]!);
Assert.Contains(Prefix + "Requesting id information for all armor/weapon inventory completed. Log file written.", harness.ChatLines);
Assert.Contains(Prefix + "Inventory sent to MosswartOverlord", harness.ChatLines);
Assert.NotNull(harness.Host.Storage.ReadText(InventoryFeature.CacheKey));
}
[Fact]
public void A_later_login_sends_at_once_and_keeps_the_saved_appraisal_of_an_unappraised_item()
{
using var harness = new PluginHarness("Mossy", arrange: host =>
Own(host, Tachi(appraised: true), TachiProps(), hostIdTime: 5000));
LoginAndSettle(harness);
harness.Tick(0.1);
harness.Tick(0.1);
Assert.Single(harness.FramesOfTypeRaw("full_inventory"));
// Relog: the session's appraisal is gone, the saved copy is not.
harness.Plugin.OnLogoff();
Own(harness.Host, Tachi(appraised: false));
harness.Frames.Clear();
harness.Host.Automation.Objects.IdentifyRequests.Clear();
LoginAndSettle(harness);
harness.Tick(0.1);
JObject frame = JObject.Parse(harness.FramesOfTypeRaw("full_inventory").Single());
JToken item = frame["items"]![0]!;
Assert.True((bool)item["HasIdData"]!);
Assert.Equal(50, (int)item["MaxDamage"]!);
Assert.Equal("Light Weapons", (string)item["EquipSkill"]!);
// Saved data present, so the original did not ask again from the dump
// (MossyInventory.cs:253); the login stream already asked once.
Assert.Equal([0x80000202u], harness.Host.Automation.Objects.IdentifyRequests);
}
[Fact]
public void Sendinventory_sends_at_once_when_everything_is_appraised()
{
using var harness = new PluginHarness("Mossy", arrange: host => Own(host, Tapers()));
LoginAndSettle(harness);
harness.Tick(0.1);
harness.Tick(0.1);
harness.Frames.Clear();
harness.Context.Commands.Dispatch("sendinventory");
Assert.Single(harness.FramesOfTypeRaw("full_inventory"));
Assert.Contains(Prefix + "[INV] All 1 items already identified. Sending...", harness.ChatLines);
Assert.Contains(Prefix + "[INV] Inventory upload completed", harness.ChatLines);
}
[Fact]
public void Sendinventory_waits_for_appraisals_and_refuses_when_streaming_is_off()
{
using var harness = new PluginHarness("Mossy", arrange: host => Own(host, Tapers()));
LoginAndSettle(harness);
harness.Tick(0.1);
harness.Tick(0.1);
harness.Frames.Clear();
Own(harness.Host, Ring());
harness.Context.Commands.Dispatch("sendinventory");
Assert.Contains(Prefix + "[INV] Scanning 2 items, requesting ID for 1. Waiting for identification...", harness.ChatLines);
harness.Tick(0.1);
Assert.Empty(harness.FramesOfTypeRaw("full_inventory"));
Own(harness.Host, Ring(), Props(ints: new() { [107] = 10, [108] = 20 }), hostIdTime: 42);
Raise(harness.Host, 0x80000404u, PluginObjectChangeKind.IdentReceived);
harness.Tick(0.1);
Assert.Single(harness.FramesOfTypeRaw("full_inventory"));
Assert.Contains(Prefix + "[INV] All items identified. Inventory upload completed.", harness.ChatLines);
harness.Context.Settings.Current.WebSocketEnabled = false;
harness.Context.Commands.Dispatch("sendinventory");
Assert.Contains(Prefix + "[INV] WebSocket streaming is disabled", harness.ChatLines);
}
[Fact]
public void The_login_stream_is_tracked_without_adds_and_items_after_it_are_added()
{
using var harness = new PluginHarness("Mossy");
harness.Login();
InventoryFeature feature = harness.Feature<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.Plugin.OnLogoff();
Assert.Single(harness.FramesOfTypeRaw("full_inventory"));
}
[Fact]
public void The_taper_count_follows_the_stacks_in_the_packs()
{
using var harness = new PluginHarness("Mossy", arrange: host => Own(host, Tapers()));
InventoryFeature feature = LoginAndSettle(harness);
Assert.Equal(57, feature.PrismaticTaperCount);
Own(harness.Host, Tapers(12));
Raise(harness.Host, 0x80000101u, PluginObjectChangeKind.Updated);
Assert.Equal(12, feature.PrismaticTaperCount);
Forget(harness.Host, 0x80000101u);
harness.Host.Events.RaiseObjectChanged(new PluginObjectChange(0x80000101u, PluginObjectChangeKind.Released));
Assert.Equal(0, feature.PrismaticTaperCount);
}
[Fact]
public void Inventory_log_off_sends_no_login_inventory()
{
using var harness = new PluginHarness("Mossy", arrange: host => Own(host, Tapers()));
harness.Login();
harness.Context.Settings.Current.InventoryLog = false;
InventoryFeature feature = harness.Feature<InventoryFeature>();
feature.IsBackendReady = () => true;
for (int i = 0; i < 40; i++)
harness.Tick(0.1);
Assert.Empty(harness.FramesOfTypeRaw("full_inventory"));
}
[Fact]
public void The_appraisal_time_is_the_wall_clock_when_the_appraisal_arrived()
{
using var harness = new PluginHarness("Mossy");
InventoryFeature feature = LoginAndSettle(harness);
Own(harness.Host, Tachi(appraised: false));
Raise(harness.Host, 0x80000202u, PluginObjectChangeKind.Created);
Own(harness.Host, Tachi(appraised: true), TachiProps(), hostIdTime: 5000);
Raise(harness.Host, 0x80000202u, PluginObjectChangeKind.IdentReceived);
harness.Clock.Advance(TimeSpan.FromMinutes(3));
harness.Frames.Clear();
feature.FlushDirtyItems();
JObject update = JObject.Parse(harness.FramesOfTypeRaw("inventory_delta").Single());
Assert.Equal(1790325015, (int)update["item"]!["LastIdTime"]!);
}
}

View file

@ -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"));
}
}

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