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:
Erik 2026-09-25 12:39:55 +02:00
parent f4a44bd9fc
commit 7a6b0a7f9d
11 changed files with 2206 additions and 2 deletions

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