acdream/tests/AcDream.Core.Net.Tests/ObjectTableWiringTests.cs
Erik db0cac03c4 fix(D.2b): PickupEvent removes the 3D render, not the weenie — unwield lands in the pack
PickupEvent (0xF74A) and DeleteObject (0xF747) are semantically distinct:
- 0xF747 = weenie DESTROYED → evict from ClientObjectTable (weenie_object_table)
- 0xF74A = object LEFT THE 3D WORLD VIEW (moved into a container) → remove
  the 3D WorldEntity, but the weenie persists in ClientObjectTable

Before this fix, both paths fired EntityDeleted identically, causing
ObjectTableWiring to evict the weenie from ClientObjectTable. The follow-up
InventoryPutObjInContainer (0x0022) then tried MoveItem on an unknown guid
and no-op'd, so the unwielded item simply vanished.

Fix: add `bool FromPickup` (default false) to DeleteObject.Parsed. WorldSession
sets it true on the PickupEvent path and false on the DeleteObject path.
ObjectTableWiring.Wire's EntityDeleted handler skips table.Remove when
FromPickup is true, preserving the weenie for the container-move echo.

GameWindow.OnLiveEntityDeleted (3D entity removal) is untouched — it fires
for both pickups and destroys, as intended.

Divergence register: AP-65 added (data ghosts for other-player pickups until
teleport/relog clear; harmless — no UI queries ContainerId 0).

Tests: +5 (DeleteObject FromPickup parser regression; wiring retain/evict
semantics; Parsed default/explicit FromPickup). 343/343 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:39:27 +02:00

176 lines
7.3 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,
};
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);
}
// -------------------------------------------------------------------------
// 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));
}
}