Lands the codex-worktree D.2b stream plus the extraction the 2026-07-02 UI architecture review mandated before commit: - ItemInteractionController: single owner of double-click use/equip/ container-open, targeted-use mode (health kits), drag-out drop; toolbar shortcut drags don't drop the real item. ItemEquipRules for multi-slot (coat) coverage via equip masks. - Cursor phase: CursorFeedbackController (semantic priority chain: drag > resize > window-move > target-mode > text) + RetailCursorCatalog (enums 0x27/0x28/0x29, hotspot 14,14; ClientUISystem::UpdateCursorState 0x00564630) resolved through the portal EnumIDMap chain by RetailCursorResolver; RetailCursorManager applies dat cursor art to the OS cursor. Register row AP-72 covers the OS standard-cursor fallback. - Character window goes live: CharacterSheetProvider owns sheet assembly, XP-curve/raise-cost math and the raise flow — extracted out of GameWindow per Code Structure Rule 1 instead of committing the ~430-line feature body there. Optimistic XP/credit debits go through eventful store APIs (new ClientObjectTable.UpdateInt64Property + LocalPlayerState.DebitIntProperty/DebitInt64Property) instead of raw property-dictionary writes; register row AP-73 covers the still-missing raise ledger (#163). - RetailWindowFrame: the shared nine-slice window mount recipe; the character window uses it, remaining windows migrate via #164. - Status-bar buttons toggle inventory/character windows; retail row-major backpack ordering; WorldSession.SendUseWithTarget + raise/train sends. GameWindow shrinks 14,214 -> 13,877 lines despite the new features; the sheet/raise logic is unit-tested in CharacterSheetProviderTests instead of trapped in the god object. Build green; full suite 3,286 tests pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
180 lines
7.6 KiB
C#
180 lines
7.6 KiB
C#
using AcDream.Core.Items;
|
|
using AcDream.Core.Net;
|
|
using AcDream.Core.Net.Messages;
|
|
|
|
namespace AcDream.Core.Net.Tests;
|
|
|
|
/// <summary>
|
|
/// D.5.4 Task 7 — ObjectTableWiring.
|
|
///
|
|
/// WorldSession.EntitySpawned/EntityDeleted have no in-process test seam (the
|
|
/// ctor opens a UDP socket), so the subscription wiring is tested via the guard
|
|
/// logic extracted as a local handler — this is the same lambda body that
|
|
/// ObjectTableWiring.Wire wires to session.EntityDeleted. The retail two-table
|
|
/// semantics are: PickupEvent (0xF74A) removes from the 3-D object_table but
|
|
/// KEEPS the weenie in ClientObjectTable; DeleteObject (0xF747) evicts the
|
|
/// weenie from both. FromPickup = true guards the eviction.
|
|
/// </summary>
|
|
public sealed class ObjectTableWiringTests
|
|
{
|
|
[Fact]
|
|
public void ToWeenieData_CopiesFieldsFromSpawn()
|
|
{
|
|
// Every EntitySpawn item field is set to a DISTINCT recognisable value so
|
|
// a positional transposition in ObjectTableWiring.ToWeenieData would trip
|
|
// at least one Assert. All 22 WeenieData fields are verified below.
|
|
var spawn = new WorldSession.EntitySpawn(
|
|
Guid: 0x00000600u,
|
|
Position: null,
|
|
SetupTableId: null,
|
|
AnimPartChanges: System.Array.Empty<CreateObject.AnimPartChange>(),
|
|
TextureChanges: System.Array.Empty<CreateObject.TextureChange>(),
|
|
SubPalettes: System.Array.Empty<CreateObject.SubPaletteSwap>(),
|
|
BasePaletteId: null,
|
|
ObjScale: null,
|
|
Name: "Iron Sword",
|
|
ItemType: (uint)ItemType.MeleeWeapon,
|
|
MotionState: null,
|
|
MotionTableId: null)
|
|
with
|
|
{
|
|
WeenieClassId = 0x00001001u,
|
|
IconId = 0x06001111u,
|
|
IconOverlayId = 0x06002222u,
|
|
IconUnderlayId = 0x06003333u,
|
|
UiEffects = 0x00000004u,
|
|
Value = 7,
|
|
StackSize = 1,
|
|
StackSizeMax = 1,
|
|
Burden = 300,
|
|
ContainerId = 0x000000C9u,
|
|
WielderId = 0x000000DAu,
|
|
ValidLocations = 0x00000012u, // MeleeWeapon wield mask
|
|
CurrentWieldedLocation = 0x00000002u, // right-hand
|
|
Priority = 0x00000005u,
|
|
ItemsCapacity = 0,
|
|
ContainersCapacity = 0,
|
|
Structure = 80,
|
|
MaxStructure = 100,
|
|
Workmanship = 4.5f,
|
|
Useability = 0x000A0008u,
|
|
TargetType = (uint)ItemType.Creature,
|
|
};
|
|
|
|
var d = ObjectTableWiring.ToWeenieData(spawn);
|
|
|
|
// --- identity ---
|
|
Assert.Equal(0x00000600u, d.Guid);
|
|
Assert.Equal("Iron Sword", d.Name);
|
|
Assert.Equal(ItemType.MeleeWeapon, d.Type);
|
|
|
|
// --- weenie / icon ---
|
|
Assert.Equal(0x00001001u, d.WeenieClassId);
|
|
Assert.Equal(0x06001111u, d.IconId);
|
|
Assert.Equal(0x06002222u, d.IconOverlayId);
|
|
Assert.Equal(0x06003333u, d.IconUnderlayId);
|
|
Assert.Equal(0x00000004u, d.Effects);
|
|
|
|
// --- quantity / economy ---
|
|
Assert.Equal(7, d.Value);
|
|
Assert.Equal(1, d.StackSize);
|
|
Assert.Equal(1, d.StackSizeMax);
|
|
Assert.Equal(300, d.Burden);
|
|
|
|
// --- container / wielder ---
|
|
Assert.Equal(0x000000C9u, d.ContainerId);
|
|
Assert.Equal(0x000000DAu, d.WielderId);
|
|
|
|
// --- equip masks ---
|
|
Assert.Equal(0x00000012u, d.ValidLocations);
|
|
Assert.Equal(0x00000002u, d.CurrentWieldedLocation);
|
|
Assert.Equal(0x00000005u, d.Priority);
|
|
|
|
// --- capacity ---
|
|
Assert.Equal(0, d.ItemsCapacity);
|
|
Assert.Equal(0, d.ContainersCapacity);
|
|
|
|
// --- durability ---
|
|
Assert.Equal(80, d.Structure);
|
|
Assert.Equal(100, d.MaxStructure);
|
|
Assert.Equal(4.5f, d.Workmanship);
|
|
Assert.Equal(0x000A0008u, d.Useability);
|
|
Assert.Equal((uint)ItemType.Creature, d.TargetType);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// The handler that ObjectTableWiring.Wire subscribes to session.EntityDeleted.
|
|
// Testing it directly avoids needing a live WorldSession (UDP socket).
|
|
// -------------------------------------------------------------------------
|
|
private static Action<DeleteObject.Parsed> MakeEntityDeletedHandler(ClientObjectTable table)
|
|
=> d => { if (!d.FromPickup) table.Remove(d.Guid); };
|
|
|
|
private static WeenieData MinimalWeenie(uint guid) => new(
|
|
Guid: guid,
|
|
Name: "Test Item",
|
|
Type: null,
|
|
WeenieClassId: 1u,
|
|
IconId: 0x06000001u,
|
|
IconOverlayId: 0u,
|
|
IconUnderlayId: 0u,
|
|
Effects: 0u,
|
|
Value: null,
|
|
StackSize: null,
|
|
StackSizeMax: null,
|
|
Burden: null,
|
|
ContainerId: null,
|
|
WielderId: null,
|
|
ValidLocations: null,
|
|
CurrentWieldedLocation: null,
|
|
Priority: null,
|
|
ItemsCapacity: null,
|
|
ContainersCapacity: null,
|
|
Structure: null,
|
|
MaxStructure: null,
|
|
Workmanship: null);
|
|
|
|
/// <summary>
|
|
/// PickupEvent path: FromPickup = true → weenie is RETAINED in ClientObjectTable.
|
|
/// The 3-D render entity is removed (EntityDeleted still fires), but the weenie
|
|
/// data persists so the follow-up InventoryPutObjInContainer can find it.
|
|
/// </summary>
|
|
[Fact]
|
|
public void EntityDeleted_FromPickupTrue_RetainsWeenieInTable()
|
|
{
|
|
var table = new ClientObjectTable();
|
|
var handler = MakeEntityDeletedHandler(table);
|
|
|
|
const uint guid = 0x80000100u;
|
|
table.Ingest(MinimalWeenie(guid));
|
|
Assert.NotNull(table.Get(guid)); // pre-condition: item is in the table
|
|
|
|
// Fire EntityDeleted as WorldSession does for PickupEvent (0xF74A).
|
|
// The handler itself represents the EntityDeleted subscription — firing it IS the event.
|
|
handler(new DeleteObject.Parsed(guid, 0, FromPickup: true));
|
|
|
|
// Post-condition: weenie still in table (it moved into a container, was NOT destroyed).
|
|
// EntityDeleted still fires (the handler ran), so the 3-D render layer would remove the WorldEntity.
|
|
Assert.NotNull(table.Get(guid));
|
|
}
|
|
|
|
/// <summary>
|
|
/// DeleteObject path: FromPickup = false → weenie IS evicted from ClientObjectTable.
|
|
/// Regression guard — true destroys (0xF747) must still clean up the table.
|
|
/// </summary>
|
|
[Fact]
|
|
public void EntityDeleted_FromPickupFalse_EvictsWeenieFromTable()
|
|
{
|
|
var table = new ClientObjectTable();
|
|
var handler = MakeEntityDeletedHandler(table);
|
|
|
|
const uint guid = 0x80000200u;
|
|
table.Ingest(MinimalWeenie(guid));
|
|
Assert.NotNull(table.Get(guid)); // pre-condition: item is in the table
|
|
|
|
// Fire EntityDeleted as WorldSession does for DeleteObject (0xF747).
|
|
handler(new DeleteObject.Parsed(guid, 0, FromPickup: false));
|
|
|
|
// Post-condition: weenie evicted (truly destroyed).
|
|
Assert.Null(table.Get(guid));
|
|
}
|
|
}
|