From 2561f5599f56b385a9e5f3c487d9de0a945eabf5 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 18 Apr 2026 16:55:36 +0200 Subject: [PATCH] feat(items): Phase F.2 ItemRepository + AppraiseRequest round-trip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the item-state mirror + appraise round-trip infrastructure on top of Phase F.1's GameEvent dispatcher. Core layer (AcDream.Core/Items): - ItemRepository: ConcurrentDictionary-backed live item state keyed by server ObjectId. Events: ItemAdded, ItemMoved, ItemRemoved, ItemPropertiesUpdated. MoveItem handles container / slot / equip location updates atomically and fires ItemMoved with old+new container ids. UpdateProperties merges a PropertyBundle patch (for appraise results) without clobbering existing untouched keys. Wire layer (AcDream.Core.Net/Messages): - AppraiseRequest (0x00C8 C→S, inside 0xF7B1 GameAction envelope): Build(sequence, targetGuid) → 16-byte body ready for SendGameAction. - GameEvents.ParseIdentifyResponseHeader for 0x00C9 S→C — extracts (guid, appraiseFlags, success). Full PropertyBundle deserialization (the 10-flag bitfield-indexed tables) is a future pass; header alone is enough to route into the repository + surface "appraise complete" to UI. - GameEvents.ParseWieldObject (0x0023) — server-driven equip. - GameEvents.ParsePutObjInContainer (0x0022) — server-driven inventory move (item, container, placement). Tests (11 new): - ItemRepository: add/update fires correct event, move updates fields, missing-id returns false, remove, properties merge, clear. - Wire: AppraiseRequest byte-exact encoding, IdentifyResponse header round-trip, WieldObject round-trip, PutObjInContainer round-trip. Build green, 532 tests pass (up from 521). Phase F.2 unblocks the Paperdoll + Inventory UI panels and the "appraise on right-click" UX. Next pieces: PropertyBundle full deserializer (AppraiseInfo 10-flag bitfield), outbound move/drop/ pickup actions. Ref: r06 §1 (ItemType), §2 (EquipMask), §5 (appraise wire), §7 (pack depth rules). Ref: ACE GameEventIdentifyObjectResponse.cs for AppraiseInfo format. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Messages/AppraiseRequest.cs | 42 +++++ src/AcDream.Core.Net/Messages/GameEvents.cs | 54 ++++++ src/AcDream.Core/Items/ItemRepository.cs | 168 ++++++++++++++++++ .../Messages/AppraiseTests.cs | 70 ++++++++ .../Items/ItemRepositoryTests.cs | 119 +++++++++++++ 5 files changed, 453 insertions(+) create mode 100644 src/AcDream.Core.Net/Messages/AppraiseRequest.cs create mode 100644 src/AcDream.Core/Items/ItemRepository.cs create mode 100644 tests/AcDream.Core.Net.Tests/Messages/AppraiseTests.cs create mode 100644 tests/AcDream.Core.Tests/Items/ItemRepositoryTests.cs diff --git a/src/AcDream.Core.Net/Messages/AppraiseRequest.cs b/src/AcDream.Core.Net/Messages/AppraiseRequest.cs new file mode 100644 index 0000000..7327f8f --- /dev/null +++ b/src/AcDream.Core.Net/Messages/AppraiseRequest.cs @@ -0,0 +1,42 @@ +using System.Buffers.Binary; + +namespace AcDream.Core.Net.Messages; + +/// +/// Outbound 0x00C8 IdentifyObject / Appraise GameAction. +/// +/// +/// Wire shape (inside the 0xF7B1 envelope): +/// +/// u32 0xF7B1 // GameMessage opcode (GameAction envelope) +/// u32 gameActionSequence // client-tracked sequence +/// u32 0x00C8 // GameAction sub-opcode +/// u32 targetGuid // whose appraisal we're requesting +/// +/// +/// +/// +/// Server replies with a 0xF7B0 / 0x00C9 IdentifyObjectResponse +/// containing the full AppraiseInfo property bundle. See +/// GameEvents.cs for the matching parser (once wired). +/// +/// +public static class AppraiseRequest +{ + public const uint GameActionEnvelope = 0xF7B1u; + public const uint SubOpcode = 0x00C8u; + + /// + /// Pack an AppraiseRequest body (with envelope) ready to be handed + /// to WorldSession.SendGameAction. + /// + public static byte[] Build(uint gameActionSequence, uint targetGuid) + { + byte[] body = new byte[16]; + BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), gameActionSequence); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), SubOpcode); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), targetGuid); + return body; + } +} diff --git a/src/AcDream.Core.Net/Messages/GameEvents.cs b/src/AcDream.Core.Net/Messages/GameEvents.cs index 0102f7c..5e962b2 100644 --- a/src/AcDream.Core.Net/Messages/GameEvents.cs +++ b/src/AcDream.Core.Net/Messages/GameEvents.cs @@ -145,6 +145,60 @@ public static class GameEvents return BinaryPrimitives.ReadUInt32LittleEndian(payload); } + // ── Appraise / identify ───────────────────────────────────────────────── + + /// 0x00C9 IdentifyObjectResponse header. + public readonly record struct IdentifyResponseHeader( + uint Guid, + uint AppraiseFlags, + bool Success); + + /// + /// Parse the header of an IdentifyObjectResponse (0x00C9). + /// Full property-bundle deserialization (int / bool / float / string + /// tables per the AppraiseFlags bitfield) is a future pass; this + /// header alone is enough for the UI to display "Appraise complete + /// on target X" and to route into the repository. + /// + public static IdentifyResponseHeader? ParseIdentifyResponseHeader(ReadOnlySpan payload) + { + if (payload.Length < 12) return null; + uint guid = BinaryPrimitives.ReadUInt32LittleEndian(payload); + uint flags = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)); + uint success = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(8)); + return new IdentifyResponseHeader(guid, flags, success != 0); + } + + /// 0x0023 WieldObject: server-driven equip. + public readonly record struct WieldObject( + uint ItemGuid, + uint EquipLoc, + uint WielderGuid); + + public static WieldObject? ParseWieldObject(ReadOnlySpan payload) + { + if (payload.Length < 12) return null; + return new WieldObject( + BinaryPrimitives.ReadUInt32LittleEndian(payload), + BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)), + BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(8))); + } + + /// 0x0022 InventoryPutObjInContainer: server puts item into container slot. + public readonly record struct InventoryPutObjInContainer( + uint ItemGuid, + uint ContainerGuid, + uint Placement); + + public static InventoryPutObjInContainer? ParsePutObjInContainer(ReadOnlySpan payload) + { + if (payload.Length < 12) return null; + return new InventoryPutObjInContainer( + BinaryPrimitives.ReadUInt32LittleEndian(payload), + BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)), + BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(8))); + } + // ── Shared string reader (matches LoginRequest.ReadString16L) ─────────── private static string ReadString16L(ReadOnlySpan source, ref int pos) diff --git a/src/AcDream.Core/Items/ItemRepository.cs b/src/AcDream.Core/Items/ItemRepository.cs new file mode 100644 index 0000000..02c864a --- /dev/null +++ b/src/AcDream.Core/Items/ItemRepository.cs @@ -0,0 +1,168 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; + +namespace AcDream.Core.Items; + +/// +/// Live item-state mirror — the client-side view of every item the +/// server has spawned for this session. Owns +/// records, tracks which container holds each item, and fires events so +/// UI panels (inventory, paperdoll, hotbars) can redraw on change. +/// +/// +/// Retail semantics (r06): +/// +/// +/// Every item is a with a unique +/// ObjectId. CreateObject seeds it when the server tells us +/// the item exists (in our inventory, on the ground, in a +/// vendor's list, etc). +/// +/// +/// Moves happen via -carrying messages: +/// WieldObject, InventoryPutObjInContainer, +/// InventoryPutObjectIn3D, ViewContents, +/// CloseGroundContainer. +/// +/// +/// InventoryServerSaveFailed reverts a speculative local +/// state change (e.g. when a drag-drop was rejected server-side). +/// +/// +/// +/// +/// +/// Thread safety: designed for single-threaded use from the render +/// thread; the event delegates run synchronously on the caller's +/// thread. A backs the +/// map so plugin code can look up items from any thread without +/// corrupting state. +/// +/// +public sealed class ItemRepository +{ + private readonly ConcurrentDictionary _items = new(); + private readonly ConcurrentDictionary _containers = new(); + + /// Fires when an item is first added to the session. + public event Action? ItemAdded; + + /// + /// Fires when an item's container / slot changes (moved between + /// packs, equipped, unequipped, dropped on ground). Old and new + /// container ids are 0 if origin or destination is "world" / "nowhere". + /// + public event Action? ItemMoved; + + /// Fires when an item is removed from the session. + public event Action? ItemRemoved; + + /// Fires when an item's properties are updated (typically after Appraise). + public event Action? ItemPropertiesUpdated; + + public int ItemCount => _items.Count; + public int ContainerCount => _containers.Count; + + public IEnumerable Items => _items.Values; + public IEnumerable Containers => _containers.Values; + + /// + /// Look up an item by its server-assigned ObjectId. + /// + public ItemInstance? GetItem(uint objectId) => + _items.TryGetValue(objectId, out var item) ? item : null; + + /// + /// Look up a container by object id, creating a lightweight stub if + /// the id doesn't match any known container (defensive — avoids losing + /// references when the server announces a move into a container it + /// hasn't described yet). + /// + public Container? GetContainer(uint objectId) => + _containers.TryGetValue(objectId, out var c) ? c : null; + + /// + /// Register / refresh an item in the repository. Called on + /// CreateObject for item-typed weenies and on IdentifyObjectResponse + /// to fill in detail properties. + /// + public void AddOrUpdate(ItemInstance item) + { + ArgumentNullException.ThrowIfNull(item); + bool existed = _items.ContainsKey(item.ObjectId); + _items[item.ObjectId] = item; + if (!existed) ItemAdded?.Invoke(item); + else ItemPropertiesUpdated?.Invoke(item); + } + + /// + /// Register a container. Idempotent. + /// + public void AddContainer(Container container) + { + ArgumentNullException.ThrowIfNull(container); + _containers[container.ObjectId] = container; + } + + /// + /// Handle a server-driven move — called from + /// InventoryPutObjInContainer (0x0022) and WieldObject (0x0023) + /// handlers. Updates ContainerId / ContainerSlot / CurrentlyEquippedLocation + /// and fires ItemMoved. + /// + public bool MoveItem(uint itemId, uint newContainerId, int newSlot = -1, + EquipMask newEquipLocation = EquipMask.None) + { + if (!_items.TryGetValue(itemId, out var item)) return false; + + uint oldContainer = item.ContainerId; + item.ContainerId = newContainerId; + item.ContainerSlot = newSlot; + item.CurrentlyEquippedLocation = newEquipLocation; + + ItemMoved?.Invoke(item, oldContainer, newContainerId); + return true; + } + + /// + /// Handle a server-driven remove (destroyed item, dropped into 3D + /// space, stolen, etc). + /// + public bool Remove(uint itemId) + { + if (!_items.TryRemove(itemId, out var item)) return false; + ItemRemoved?.Invoke(item); + return true; + } + + /// + /// Apply a patch (e.g. from an + /// IdentifyObjectResponse) to an existing item. Individual + /// keys in the incoming bundle overwrite existing values; keys not + /// present are left untouched. + /// + public bool UpdateProperties(uint itemId, PropertyBundle incoming) + { + if (!_items.TryGetValue(itemId, out var item)) return false; + foreach (var kv in incoming.Ints) item.Properties.Ints[kv.Key] = kv.Value; + foreach (var kv in incoming.Int64s) item.Properties.Int64s[kv.Key] = kv.Value; + foreach (var kv in incoming.Bools) item.Properties.Bools[kv.Key] = kv.Value; + foreach (var kv in incoming.Floats) item.Properties.Floats[kv.Key] = kv.Value; + foreach (var kv in incoming.Strings) item.Properties.Strings[kv.Key] = kv.Value; + foreach (var kv in incoming.DataIds) item.Properties.DataIds[kv.Key] = kv.Value; + foreach (var kv in incoming.InstanceIds) item.Properties.InstanceIds[kv.Key] = kv.Value; + ItemPropertiesUpdated?.Invoke(item); + return true; + } + + /// + /// Flush the repository — typically called on logoff or teleport + /// that drops the session's item state. + /// + public void Clear() + { + _items.Clear(); + _containers.Clear(); + } +} diff --git a/tests/AcDream.Core.Net.Tests/Messages/AppraiseTests.cs b/tests/AcDream.Core.Net.Tests/Messages/AppraiseTests.cs new file mode 100644 index 0000000..79dcd1d --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Messages/AppraiseTests.cs @@ -0,0 +1,70 @@ +using System; +using System.Buffers.Binary; +using AcDream.Core.Net.Messages; +using Xunit; + +namespace AcDream.Core.Net.Tests.Messages; + +public sealed class AppraiseTests +{ + [Fact] + public void AppraiseRequest_Build_EmitsCorrectWireBytes() + { + byte[] body = AppraiseRequest.Build(gameActionSequence: 7, targetGuid: 0x12345678u); + + Assert.Equal(16, body.Length); + Assert.Equal(AppraiseRequest.GameActionEnvelope, + BinaryPrimitives.ReadUInt32LittleEndian(body)); + Assert.Equal(7u, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4))); + Assert.Equal(AppraiseRequest.SubOpcode, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); + Assert.Equal(0x12345678u, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12))); + } + + [Fact] + public void ParseIdentifyResponseHeader_RoundTrip() + { + byte[] payload = new byte[12]; + BinaryPrimitives.WriteUInt32LittleEndian(payload, 0xDEADBEEFu); // guid + BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 0x000F); // flags + BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 1u); // success + + var parsed = GameEvents.ParseIdentifyResponseHeader(payload); + Assert.NotNull(parsed); + Assert.Equal(0xDEADBEEFu, parsed!.Value.Guid); + Assert.Equal(0x000Fu, parsed.Value.AppraiseFlags); + Assert.True(parsed.Value.Success); + } + + [Fact] + public void ParseWieldObject_RoundTrip() + { + byte[] payload = new byte[12]; + BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x1000u); + BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 0x00400000u); // MeleeWeapon slot + BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 0x2000u); // wielder + + var parsed = GameEvents.ParseWieldObject(payload); + Assert.NotNull(parsed); + Assert.Equal(0x1000u, parsed!.Value.ItemGuid); + Assert.Equal(0x00400000u, parsed.Value.EquipLoc); + Assert.Equal(0x2000u, parsed.Value.WielderGuid); + } + + [Fact] + public void ParsePutObjInContainer_RoundTrip() + { + byte[] payload = new byte[12]; + BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x1001u); + BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 0x2001u); + BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 3u); + + var parsed = GameEvents.ParsePutObjInContainer(payload); + Assert.NotNull(parsed); + Assert.Equal(0x1001u, parsed!.Value.ItemGuid); + Assert.Equal(0x2001u, parsed.Value.ContainerGuid); + Assert.Equal(3u, parsed.Value.Placement); + } +} diff --git a/tests/AcDream.Core.Tests/Items/ItemRepositoryTests.cs b/tests/AcDream.Core.Tests/Items/ItemRepositoryTests.cs new file mode 100644 index 0000000..79fe2ef --- /dev/null +++ b/tests/AcDream.Core.Tests/Items/ItemRepositoryTests.cs @@ -0,0 +1,119 @@ +using AcDream.Core.Items; +using Xunit; + +namespace AcDream.Core.Tests.Items; + +public sealed class ItemRepositoryTests +{ + private static ItemInstance MakeItem(uint id, string name = "Widget") => + new ItemInstance + { + ObjectId = id, + WeenieClassId = 1, + Name = name, + Type = ItemType.Misc, + StackSize = 1, + Burden = 10, + Value = 5, + }; + + [Fact] + public void AddOrUpdate_FiresAddedEvent() + { + var repo = new ItemRepository(); + ItemInstance? added = null; + repo.ItemAdded += i => added = i; + + var item = MakeItem(100); + repo.AddOrUpdate(item); + + Assert.Same(item, added); + Assert.Equal(1, repo.ItemCount); + Assert.Same(item, repo.GetItem(100)); + } + + [Fact] + public void AddOrUpdate_ExistingItem_FiresPropertiesUpdated() + { + var repo = new ItemRepository(); + var item = MakeItem(100); + repo.AddOrUpdate(item); + + int propUpdateCount = 0; + repo.ItemPropertiesUpdated += _ => propUpdateCount++; + + repo.AddOrUpdate(item); // second call is an update + Assert.Equal(1, propUpdateCount); + } + + [Fact] + public void MoveItem_UpdatesContainerAndFiresEvent() + { + var repo = new ItemRepository(); + var item = MakeItem(100); + repo.AddOrUpdate(item); + + uint seenOld = 999, seenNew = 999; + repo.ItemMoved += (it, oldC, newC) => { seenOld = oldC; seenNew = newC; }; + + repo.MoveItem(100, 42, newSlot: 3); + + Assert.Equal(0u, seenOld); // was not in any container initially + Assert.Equal(42u, seenNew); + Assert.Equal(42u, item.ContainerId); + Assert.Equal(3, item.ContainerSlot); + } + + [Fact] + public void MoveItem_Nonexistent_ReturnsFalse() + { + var repo = new ItemRepository(); + Assert.False(repo.MoveItem(999, 42)); + } + + [Fact] + public void Remove_FiresEventAndRemoves() + { + var repo = new ItemRepository(); + var item = MakeItem(100); + repo.AddOrUpdate(item); + + ItemInstance? removed = null; + repo.ItemRemoved += i => removed = i; + + Assert.True(repo.Remove(100)); + Assert.Same(item, removed); + Assert.Null(repo.GetItem(100)); + } + + [Fact] + public void UpdateProperties_MergesIncomingBundle() + { + var repo = new ItemRepository(); + var item = MakeItem(100); + item.Properties.Ints[1] = 10; + repo.AddOrUpdate(item); + + var patch = new PropertyBundle(); + patch.Ints[2] = 20; // new + patch.Ints[1] = 15; // overrides + patch.Strings[100] = "desc"; + repo.UpdateProperties(100, patch); + + Assert.Equal(15, item.Properties.Ints[1]); + Assert.Equal(20, item.Properties.Ints[2]); + Assert.Equal("desc", item.Properties.Strings[100]); + } + + [Fact] + public void Clear_RemovesAllItems() + { + var repo = new ItemRepository(); + repo.AddOrUpdate(MakeItem(1)); + repo.AddOrUpdate(MakeItem(2)); + repo.AddOrUpdate(MakeItem(3)); + + repo.Clear(); + Assert.Equal(0, repo.ItemCount); + } +}