Keep the retail inventory transaction busy until authoritative WieldObject confirms the requested weapon. This prevents rapid weapon changes from overlapping on ACE and rejecting otherwise valid items such as spears. Co-Authored-By: Codex <codex@openai.com>
799 lines
31 KiB
C#
799 lines
31 KiB
C#
using AcDream.Core.Items;
|
|
using Xunit;
|
|
|
|
namespace AcDream.Core.Tests.Items;
|
|
|
|
public sealed class ClientObjectTableTests
|
|
{
|
|
private static ClientObject MakeItem(uint id, string name = "Widget") =>
|
|
new ClientObject
|
|
{
|
|
ObjectId = id,
|
|
WeenieClassId = 1,
|
|
Name = name,
|
|
Type = ItemType.Misc,
|
|
StackSize = 1,
|
|
Burden = 10,
|
|
Value = 5,
|
|
};
|
|
|
|
[Fact]
|
|
public void AddOrUpdate_FiresAddedEvent()
|
|
{
|
|
var repo = new ClientObjectTable();
|
|
ClientObject? added = null;
|
|
repo.ObjectAdded += i => added = i;
|
|
|
|
var item = MakeItem(100);
|
|
repo.AddOrUpdate(item);
|
|
|
|
Assert.Same(item, added);
|
|
Assert.Equal(1, repo.ObjectCount);
|
|
Assert.Same(item, repo.Get(100));
|
|
}
|
|
|
|
[Fact]
|
|
public void AddOrUpdate_ExistingItem_FiresPropertiesUpdated()
|
|
{
|
|
var repo = new ClientObjectTable();
|
|
var item = MakeItem(100);
|
|
repo.AddOrUpdate(item);
|
|
|
|
int propUpdateCount = 0;
|
|
repo.ObjectUpdated += _ => propUpdateCount++;
|
|
|
|
repo.AddOrUpdate(item); // second call is an update
|
|
Assert.Equal(1, propUpdateCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void MoveItem_UpdatesContainerAndFiresEvent()
|
|
{
|
|
var repo = new ClientObjectTable();
|
|
var item = MakeItem(100);
|
|
repo.AddOrUpdate(item);
|
|
|
|
uint seenOld = 999, seenNew = 999;
|
|
repo.ObjectMoved += (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 ClientObjectTable();
|
|
Assert.False(repo.MoveItem(999, 42));
|
|
}
|
|
|
|
[Fact]
|
|
public void Remove_FiresEventAndRemoves()
|
|
{
|
|
var repo = new ClientObjectTable();
|
|
var item = MakeItem(100);
|
|
repo.AddOrUpdate(item);
|
|
|
|
ClientObject? removed = null;
|
|
repo.ObjectRemoved += i => removed = i;
|
|
|
|
Assert.True(repo.Remove(100));
|
|
Assert.Same(item, removed);
|
|
Assert.Null(repo.Get(100));
|
|
}
|
|
|
|
[Fact]
|
|
public void UpdateProperties_MergesIncomingBundle()
|
|
{
|
|
var repo = new ClientObjectTable();
|
|
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 ClientObjectTable();
|
|
repo.AddOrUpdate(MakeItem(1));
|
|
repo.AddOrUpdate(MakeItem(2));
|
|
repo.AddOrUpdate(MakeItem(3));
|
|
|
|
repo.Clear();
|
|
Assert.Equal(0, repo.ObjectCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void UpdateIntProperty_uiEffects_setsEffectsAndFires()
|
|
{
|
|
var repo = new ClientObjectTable();
|
|
repo.AddOrUpdate(new ClientObject { ObjectId = 0x500000ABu });
|
|
ClientObject? fired = null;
|
|
repo.ObjectUpdated += i => fired = i;
|
|
bool ok = repo.UpdateIntProperty(0x500000ABu, ClientObjectTable.UiEffectsPropertyId, value: 0x9);
|
|
Assert.True(ok);
|
|
Assert.Equal(0x9u, repo.Get(0x500000ABu)!.Effects);
|
|
Assert.Equal(0x9, repo.Get(0x500000ABu)!.Properties.Ints[ClientObjectTable.UiEffectsPropertyId]);
|
|
Assert.NotNull(fired);
|
|
}
|
|
|
|
[Fact]
|
|
public void UpdateIntProperty_unknownItem_returnsFalse()
|
|
{
|
|
var repo = new ClientObjectTable();
|
|
Assert.False(repo.UpdateIntProperty(0xDEADBEEFu, 18u, 1));
|
|
}
|
|
|
|
[Fact]
|
|
public void UpdateIntProperty_uiEffectsClearedToZero_clearsEffects()
|
|
{
|
|
// The core "item with mana vs out of mana" promise: a draining item whose
|
|
// UiEffects clears to 0 must return to its base (un-tinted) icon. Guards
|
|
// against a future `if (value != 0)` regression on the unconditional assign.
|
|
var repo = new ClientObjectTable();
|
|
repo.AddOrUpdate(new ClientObject { ObjectId = 0x500000ACu, Effects = 0x1u });
|
|
repo.UpdateIntProperty(0x500000ACu, ClientObjectTable.UiEffectsPropertyId, value: 0x1);
|
|
Assert.Equal(0x1u, repo.Get(0x500000ACu)!.Effects);
|
|
repo.UpdateIntProperty(0x500000ACu, ClientObjectTable.UiEffectsPropertyId, value: 0);
|
|
Assert.Equal(0u, repo.Get(0x500000ACu)!.Effects);
|
|
}
|
|
|
|
[Fact]
|
|
public void UpdateIntProperty_currentWieldedLocation_updatesTypedEquipLocation()
|
|
{
|
|
var repo = new ClientObjectTable();
|
|
repo.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = 0x500000ADu,
|
|
CurrentlyEquippedLocation = EquipMask.ChestArmor,
|
|
});
|
|
|
|
Assert.True(repo.UpdateIntProperty(
|
|
0x500000ADu,
|
|
ClientObjectTable.CurrentWieldedLocationPropertyId,
|
|
value: 0));
|
|
|
|
Assert.Equal(EquipMask.None, repo.Get(0x500000ADu)!.CurrentlyEquippedLocation);
|
|
Assert.Equal(0, repo.Get(0x500000ADu)!.Properties.Ints[ClientObjectTable.CurrentWieldedLocationPropertyId]);
|
|
}
|
|
|
|
[Fact]
|
|
public void ClientObject_NewFields_DefaultAndSettable()
|
|
{
|
|
var o = new ClientObject
|
|
{
|
|
ObjectId = 1, WielderId = 0x42u, ItemsCapacity = 24, ContainersCapacity = 7,
|
|
ContainerTypeHint = 1u, Priority = 8u, Structure = 5, MaxStructure = 10, Workmanship = 7.5f,
|
|
};
|
|
o.WeenieClassId = 0xABCDu; // now settable
|
|
Assert.Equal(0x42u, o.WielderId);
|
|
Assert.Equal(24, o.ItemsCapacity);
|
|
Assert.Equal(7, o.ContainersCapacity);
|
|
Assert.Equal(1u, o.ContainerTypeHint);
|
|
Assert.Equal(8u, o.Priority);
|
|
Assert.Equal(5, o.Structure);
|
|
Assert.Equal(10, o.MaxStructure);
|
|
Assert.Equal(7.5f, o.Workmanship);
|
|
Assert.Equal(0xABCDu, o.WeenieClassId);
|
|
}
|
|
|
|
[Fact]
|
|
public void WeenieData_Construct()
|
|
{
|
|
var d = new WeenieData(Guid: 1, Name: "x", Type: ItemType.Misc, WeenieClassId: 2,
|
|
IconId: 0x06001234u, IconOverlayId: 0, IconUnderlayId: 0, Effects: 0,
|
|
Value: 5, StackSize: 1, StackSizeMax: 1, Burden: 10,
|
|
ContainerId: 0x99u, WielderId: null, ValidLocations: null,
|
|
CurrentWieldedLocation: null, Priority: null,
|
|
ItemsCapacity: null, ContainersCapacity: null,
|
|
Structure: null, MaxStructure: null, Workmanship: null);
|
|
Assert.Equal(0x99u, d.ContainerId);
|
|
}
|
|
|
|
[Fact]
|
|
public void Ingest_PetOwnerFromSecondHeader_IsPreserved()
|
|
{
|
|
var table = new ClientObjectTable();
|
|
var data = new WeenieData(
|
|
Guid: 0x50000010u, Name: "Combat Pet", Type: ItemType.Creature,
|
|
WeenieClassId: 2, IconId: 0, IconOverlayId: 0, IconUnderlayId: 0, Effects: 0,
|
|
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,
|
|
PetOwnerId: 0x50000001u);
|
|
|
|
ClientObject result = table.Ingest(data);
|
|
|
|
Assert.Equal(0x50000001u, result.PetOwnerId);
|
|
}
|
|
|
|
private static WeenieData FullWeenie(uint guid, uint icon = 0x06001234u,
|
|
string name = "Sword", ItemType type = ItemType.MeleeWeapon, uint effects = 0,
|
|
int? value = 100, int? stack = 1, uint? container = null, uint wcid = 0xABCDu) =>
|
|
new WeenieData(guid, name, type, wcid, icon, 0, 0, effects,
|
|
value, stack, StackSizeMax: 1, Burden: 10, ContainerId: container,
|
|
WielderId: null, ValidLocations: null, CurrentWieldedLocation: null,
|
|
Priority: null, ItemsCapacity: null, ContainersCapacity: null,
|
|
Structure: null, MaxStructure: null, Workmanship: null);
|
|
|
|
[Fact]
|
|
public void Ingest_NewItemWithNoPriorStub_Creates_AndFiresAdded() // the Coldeve bug
|
|
{
|
|
var table = new ClientObjectTable();
|
|
ClientObject? added = null;
|
|
table.ObjectAdded += o => added = o;
|
|
var obj = table.Ingest(FullWeenie(0x500000B0u));
|
|
Assert.NotNull(added);
|
|
Assert.Equal(0x06001234u, table.Get(0x500000B0u)!.IconId);
|
|
Assert.Equal(0xABCDu, obj.WeenieClassId);
|
|
}
|
|
|
|
[Fact]
|
|
public void Ingest_Existing_PatchesInPlace_PreservesPropertyBundle()
|
|
{
|
|
var table = new ClientObjectTable();
|
|
table.Ingest(FullWeenie(0x500000B1u));
|
|
table.Get(0x500000B1u)!.Properties.Ints[999u] = 7; // simulate appraise
|
|
ClientObject? updated = null;
|
|
table.ObjectUpdated += o => updated = o;
|
|
table.Ingest(FullWeenie(0x500000B1u, name: "Renamed"));
|
|
Assert.NotNull(updated);
|
|
Assert.Equal("Renamed", table.Get(0x500000B1u)!.Name);
|
|
Assert.Equal(7, table.Get(0x500000B1u)!.Properties.Ints[999u]); // NOT clobbered
|
|
}
|
|
|
|
[Fact]
|
|
public void Ingest_AbsentNullableField_DoesNotClobber()
|
|
{
|
|
var table = new ClientObjectTable();
|
|
table.Ingest(FullWeenie(0x500000B2u, value: 100));
|
|
var noValue = FullWeenie(0x500000B2u) with { Value = null };
|
|
table.Ingest(noValue);
|
|
Assert.Equal(100, table.Get(0x500000B2u)!.Value);
|
|
}
|
|
|
|
[Fact]
|
|
public void Ingest_Effects_AssignedUnconditionally_ClearsToZero() // D.5.2 contract
|
|
{
|
|
var table = new ClientObjectTable();
|
|
table.Ingest(FullWeenie(0x500000B3u, effects: 0x1u));
|
|
Assert.Equal(0x1u, table.Get(0x500000B3u)!.Effects);
|
|
table.Ingest(FullWeenie(0x500000B3u, effects: 0u));
|
|
Assert.Equal(0u, table.Get(0x500000B3u)!.Effects);
|
|
}
|
|
|
|
[Fact]
|
|
public void Ingest_UseabilityAndTargetType_AssignsTypedFields()
|
|
{
|
|
var table = new ClientObjectTable();
|
|
var data = FullWeenie(0x500000B7u) with
|
|
{
|
|
Useability = 0x000A0008u,
|
|
TargetType = (uint)ItemType.Creature,
|
|
};
|
|
|
|
table.Ingest(data);
|
|
|
|
var item = table.Get(0x500000B7u);
|
|
Assert.NotNull(item);
|
|
Assert.Equal(0x000A0008u, item!.Useability);
|
|
Assert.Equal((uint)ItemType.Creature, item.TargetType);
|
|
}
|
|
|
|
[Fact]
|
|
public void Ingest_PluralName_PreservesWireValue()
|
|
{
|
|
var table = new ClientObjectTable();
|
|
|
|
table.Ingest(FullWeenie(0x500000B9u, name: "Pyreal Scarab", stack: 2) with
|
|
{
|
|
PluralName = "Pyreal Scarabs",
|
|
});
|
|
|
|
Assert.Equal("Pyreal Scarabs", table.Get(0x500000B9u)!.PluralName);
|
|
Assert.Equal("Pyreal Scarabs", table.Get(0x500000B9u)!.GetAppropriateName());
|
|
}
|
|
|
|
[Fact]
|
|
public void Ingest_AmmoType_PreservesWireValueForAutoWieldCompatibility()
|
|
{
|
|
var table = new ClientObjectTable();
|
|
|
|
table.Ingest(FullWeenie(0x500000BAu, name: "Bow",
|
|
type: ItemType.MissileWeapon) with
|
|
{
|
|
CombatUse = 2,
|
|
AmmoType = 1,
|
|
});
|
|
|
|
Assert.Equal((byte)2, table.Get(0x500000BAu)!.CombatUse);
|
|
Assert.Equal((ushort)1, table.Get(0x500000BAu)!.AmmoType);
|
|
}
|
|
|
|
[Fact]
|
|
public void Ingest_RadarMetadata_PatchesOnlyPresentWireFields()
|
|
{
|
|
var table = new ClientObjectTable();
|
|
table.Ingest(FullWeenie(0x500000B8u) with
|
|
{
|
|
RadarBlipColor = 4,
|
|
RadarBehavior = 3,
|
|
});
|
|
|
|
table.Ingest(FullWeenie(0x500000B8u) with
|
|
{
|
|
RadarBlipColor = null,
|
|
RadarBehavior = 4,
|
|
});
|
|
|
|
var item = table.Get(0x500000B8u);
|
|
Assert.NotNull(item);
|
|
Assert.Equal((byte)4, item!.RadarBlipColor);
|
|
Assert.Equal((byte)4, item.RadarBehavior);
|
|
}
|
|
|
|
[Fact]
|
|
public void RecordMembership_CreatesEntry_AndSetsEquip()
|
|
{
|
|
var table = new ClientObjectTable();
|
|
table.RecordMembership(0x500000B4u, equip: EquipMask.MeleeWeapon);
|
|
var o = table.Get(0x500000B4u);
|
|
Assert.NotNull(o);
|
|
Assert.Equal(EquipMask.MeleeWeapon, o!.CurrentlyEquippedLocation);
|
|
Assert.Equal(0u, o.IconId); // data not set — CreateObject fills it
|
|
}
|
|
|
|
[Fact]
|
|
public void Ingest_AfterMembership_FillsData_NoDuplicate() // out-of-order: PD then CreateObject
|
|
{
|
|
var table = new ClientObjectTable();
|
|
table.RecordMembership(0x500000B5u);
|
|
table.Ingest(FullWeenie(0x500000B5u));
|
|
Assert.Equal(1, table.ObjectCount);
|
|
Assert.Equal(0x06001234u, table.Get(0x500000B5u)!.IconId);
|
|
}
|
|
|
|
[Fact]
|
|
public void Membership_AfterIngest_NoDuplicate_PreservesData() // out-of-order: CreateObject then PD
|
|
{
|
|
var table = new ClientObjectTable();
|
|
table.Ingest(FullWeenie(0x500000B6u)); // CreateObject first (ground/vendor item)
|
|
table.RecordMembership(0x500000B6u, equip: EquipMask.MeleeWeapon); // then PD manifest
|
|
Assert.Equal(1, table.ObjectCount);
|
|
Assert.Equal(0x06001234u, table.Get(0x500000B6u)!.IconId); // data NOT clobbered by membership
|
|
Assert.Equal(EquipMask.MeleeWeapon, table.Get(0x500000B6u)!.CurrentlyEquippedLocation);
|
|
}
|
|
|
|
[Fact]
|
|
public void RecordMembership_inventoryEntryClearsStaleEquipLocation()
|
|
{
|
|
var table = new ClientObjectTable();
|
|
table.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = 0x500000B8u,
|
|
CurrentlyEquippedLocation = EquipMask.ChestWear | EquipMask.UpperArmWear,
|
|
});
|
|
|
|
table.RecordMembership(0x500000B8u);
|
|
|
|
Assert.Equal(EquipMask.None, table.Get(0x500000B8u)!.CurrentlyEquippedLocation);
|
|
}
|
|
|
|
[Fact]
|
|
public void ContainerIndex_IngestThenContents_OrderedBySlot()
|
|
{
|
|
var table = new ClientObjectTable();
|
|
table.Ingest(FullWeenie(0x510u, container: 0xC0u));
|
|
table.Ingest(FullWeenie(0x511u, container: 0xC0u));
|
|
table.MoveItem(0x510u, 0xC0u, newSlot: 1);
|
|
table.MoveItem(0x511u, 0xC0u, newSlot: 0);
|
|
Assert.Equal(new[] { 0x511u, 0x510u }, table.GetContents(0xC0u));
|
|
}
|
|
|
|
[Fact]
|
|
public void ContainerIndex_Move_ReparentsBetweenContainers()
|
|
{
|
|
var table = new ClientObjectTable();
|
|
table.Ingest(FullWeenie(0x520u, container: 0xC1u));
|
|
table.MoveItem(0x520u, 0xC2u, newSlot: 0);
|
|
Assert.Empty(table.GetContents(0xC1u));
|
|
Assert.Equal(new[] { 0x520u }, table.GetContents(0xC2u));
|
|
}
|
|
|
|
[Fact]
|
|
public void ContainerIndex_Remove_DropsFromContents()
|
|
{
|
|
var table = new ClientObjectTable();
|
|
table.Ingest(FullWeenie(0x530u, container: 0xC3u));
|
|
table.Remove(0x530u);
|
|
Assert.Empty(table.GetContents(0xC3u));
|
|
}
|
|
|
|
[Fact]
|
|
public void GetContents_UnknownContainer_Empty()
|
|
{
|
|
var table = new ClientObjectTable();
|
|
Assert.Empty(table.GetContents(0xDEADu));
|
|
}
|
|
|
|
[Fact]
|
|
public void Ingest_CreatureTyped_ResolvesNameAndTypeViaGet() // spec §8: selection/describe creature resolution after _liveEntityInfoByGuid retirement
|
|
{
|
|
var table = new ClientObjectTable();
|
|
table.Ingest(FullWeenie(0x560u, name: "Drudge", type: ItemType.Creature));
|
|
var o = table.Get(0x560u);
|
|
Assert.NotNull(o);
|
|
Assert.Equal("Drudge", o!.Name); // LiveName(guid) reads this
|
|
Assert.True((o.Type & ItemType.Creature) != 0); // LiveItemType(guid) & Creature drives creature targeting
|
|
}
|
|
|
|
[Fact]
|
|
public void ReplaceContents_RecordsAllInListOrder()
|
|
{
|
|
var table = new ClientObjectTable();
|
|
table.ReplaceContents(0xC9u, new uint[] { 0xA01u, 0xA02u, 0xA03u });
|
|
Assert.Equal(new[] { 0xA01u, 0xA02u, 0xA03u }, table.GetContents(0xC9u));
|
|
Assert.Equal(0xC9u, table.Get(0xA01u)!.ContainerId);
|
|
}
|
|
|
|
[Fact]
|
|
public void ReplaceContents_SecondSmallerList_DetachesDropped() // the full-REPLACE semantics (vs the old additive merge)
|
|
{
|
|
var table = new ClientObjectTable();
|
|
table.ReplaceContents(0xC9u, new uint[] { 0xA01u, 0xA02u, 0xA03u });
|
|
table.ReplaceContents(0xC9u, new uint[] { 0xA02u }); // A01 + A03 removed server-side
|
|
Assert.Equal(new[] { 0xA02u }, table.GetContents(0xC9u));
|
|
Assert.Equal(0u, table.Get(0xA01u)!.ContainerId); // detached, not lingering
|
|
Assert.Equal(0u, table.Get(0xA03u)!.ContainerId);
|
|
Assert.Equal(0xC9u, table.Get(0xA02u)!.ContainerId);
|
|
}
|
|
|
|
[Fact]
|
|
public void ReplaceContents_listedMemberClearsStaleEquipLocation()
|
|
{
|
|
var table = new ClientObjectTable();
|
|
table.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = 0xA10u,
|
|
ContainerId = 0x50000001u,
|
|
CurrentlyEquippedLocation = EquipMask.ChestWear | EquipMask.UpperArmWear,
|
|
});
|
|
|
|
table.ReplaceContents(0x50000001u, new uint[] { 0xA10u });
|
|
|
|
var item = table.Get(0xA10u)!;
|
|
Assert.Equal(0x50000001u, item.ContainerId);
|
|
Assert.Equal(0, item.ContainerSlot);
|
|
Assert.Equal(EquipMask.None, item.CurrentlyEquippedLocation);
|
|
}
|
|
|
|
[Fact]
|
|
public void ReplaceContents_detachedMemberClearsStaleEquipLocation()
|
|
{
|
|
var table = new ClientObjectTable();
|
|
table.AddOrUpdate(new ClientObject { ObjectId = 0xA11u });
|
|
table.MoveItem(0xA11u, 0x50000001u, newSlot: 0,
|
|
newEquipLocation: EquipMask.ChestWear | EquipMask.UpperArmWear);
|
|
|
|
table.ReplaceContents(0x50000001u, Array.Empty<uint>());
|
|
|
|
var item = table.Get(0xA11u)!;
|
|
Assert.Equal(0u, item.ContainerId);
|
|
Assert.Equal(EquipMask.None, item.CurrentlyEquippedLocation);
|
|
}
|
|
|
|
[Fact]
|
|
public void ReplaceContents_ReordersToNewListOrder()
|
|
{
|
|
var table = new ClientObjectTable();
|
|
table.ReplaceContents(0xC9u, new uint[] { 0xA01u, 0xA02u });
|
|
table.ReplaceContents(0xC9u, new uint[] { 0xA02u, 0xA01u });
|
|
Assert.Equal(new[] { 0xA02u, 0xA01u }, table.GetContents(0xC9u));
|
|
}
|
|
|
|
[Fact]
|
|
public void ReplaceContents_RecordsContainerTypeHintAndOrder()
|
|
{
|
|
var table = new ClientObjectTable();
|
|
table.ReplaceContents(0xC9u, new[]
|
|
{
|
|
new ContainerContentEntry(0xA02u, 1u),
|
|
new ContainerContentEntry(0xA01u, 0u),
|
|
});
|
|
|
|
Assert.Equal(new[] { 0xA02u, 0xA01u }, table.GetContents(0xC9u));
|
|
Assert.Equal(1u, table.Get(0xA02u)!.ContainerTypeHint);
|
|
Assert.Equal(0u, table.Get(0xA01u)!.ContainerTypeHint);
|
|
}
|
|
|
|
[Fact]
|
|
public void ReplaceContents_ManifestOrderSurvivesLaterCreateObjects()
|
|
{
|
|
var table = new ClientObjectTable();
|
|
const uint player = 0x50000001u;
|
|
|
|
table.ReplaceContents(player, new[]
|
|
{
|
|
new ContainerContentEntry(0xA01u, 0u),
|
|
new ContainerContentEntry(0xA02u, 1u),
|
|
});
|
|
table.Ingest(FullWeenie(0xA02u, container: player, type: ItemType.Container));
|
|
table.Ingest(FullWeenie(0xA01u, container: player));
|
|
|
|
Assert.Equal(new[] { 0xA01u, 0xA02u }, table.GetContents(player));
|
|
Assert.Equal(0, table.Get(0xA01u)!.ContainerSlot);
|
|
Assert.Equal(1, table.Get(0xA02u)!.ContainerSlot);
|
|
Assert.Equal(1u, table.Get(0xA02u)!.ContainerTypeHint);
|
|
}
|
|
|
|
[Fact]
|
|
public void Reindex_UnknownSlotAppendsAfterKnownSnapshotSlots()
|
|
{
|
|
var table = new ClientObjectTable();
|
|
const uint pack = 0x500000C9u;
|
|
|
|
table.ReplaceContents(pack, new uint[] { 0xA01u, 0xA02u });
|
|
table.Ingest(FullWeenie(0xA03u, container: pack));
|
|
|
|
Assert.Equal(new[] { 0xA01u, 0xA02u, 0xA03u }, table.GetContents(pack));
|
|
Assert.Equal(-1, table.Get(0xA03u)!.ContainerSlot);
|
|
}
|
|
|
|
[Fact]
|
|
public void ReplaceContents_ZeroContainer_NoOp()
|
|
{
|
|
var table = new ClientObjectTable();
|
|
table.ReplaceContents(0u, new uint[] { 0xA01u });
|
|
Assert.Empty(table.GetContents(0u));
|
|
Assert.Null(table.Get(0xA01u));
|
|
}
|
|
|
|
[Fact]
|
|
public void MoveItemOptimistic_movesAndRemembersPreMove()
|
|
{
|
|
var table = new ClientObjectTable();
|
|
table.Ingest(FullWeenie(0x700u, container: 0xC0u)); table.MoveItem(0x700u, 0xC0u, newSlot: 3);
|
|
table.MoveItemOptimistic(0x700u, 0xC1u, 0);
|
|
Assert.Equal(0xC1u, table.Get(0x700u)!.ContainerId); // moved now (instant)
|
|
Assert.True(table.RollbackMove(0x700u)); // pre-move was remembered
|
|
Assert.Equal(0xC0u, table.Get(0x700u)!.ContainerId); // snapped back to the original container
|
|
Assert.Equal(3, table.Get(0x700u)!.ContainerSlot); // and slot
|
|
}
|
|
|
|
[Fact]
|
|
public void ConfirmMove_clearsPending_soRollbackIsNoOp()
|
|
{
|
|
var table = new ClientObjectTable();
|
|
table.Ingest(FullWeenie(0x701u, container: 0xC0u));
|
|
table.MoveItemOptimistic(0x701u, 0xC1u, 0);
|
|
table.ConfirmMove(0x701u);
|
|
Assert.False(table.RollbackMove(0x701u)); // nothing pending → no rollback
|
|
Assert.Equal(0xC1u, table.Get(0x701u)!.ContainerId); // stays at the confirmed container
|
|
}
|
|
|
|
[Fact]
|
|
public void MoveItemOptimistic_twice_keepsTheOriginalPreMove()
|
|
{
|
|
var table = new ClientObjectTable();
|
|
table.Ingest(FullWeenie(0x702u, container: 0xC0u));
|
|
table.MoveItemOptimistic(0x702u, 0xC1u, 0);
|
|
table.MoveItemOptimistic(0x702u, 0xC2u, 0); // a second move before any confirm
|
|
table.RollbackMove(0x702u);
|
|
Assert.Equal(0xC0u, table.Get(0x702u)!.ContainerId); // rolls back to the FIRST pre-move
|
|
}
|
|
|
|
[Fact]
|
|
public void RollbackMove_unknownItem_false()
|
|
=> Assert.False(new ClientObjectTable().RollbackMove(0xDEADu));
|
|
|
|
[Fact]
|
|
public void MoveItemOptimistic_twice_oneConfirm_stillRollsBack() // I1: an early confirm must not strand the item
|
|
{
|
|
var table = new ClientObjectTable();
|
|
table.Ingest(FullWeenie(0x910u, container: 0xC0u)); table.MoveItem(0x910u, 0xC0u, 5);
|
|
table.MoveItemOptimistic(0x910u, 0xC1u, 0); // move 1 (outstanding 1, snapshot C0/5)
|
|
table.MoveItemOptimistic(0x910u, 0xC2u, 0); // move 2 (outstanding 2)
|
|
table.ConfirmMove(0x910u); // server confirms move 1 → outstanding 1, STILL pending
|
|
Assert.True(table.RollbackMove(0x910u)); // move 2 rejected → can still roll back (not stranded at C2)
|
|
Assert.Equal(0xC0u, table.Get(0x910u)!.ContainerId);
|
|
Assert.Equal(5, table.Get(0x910u)!.ContainerSlot);
|
|
}
|
|
|
|
[Fact]
|
|
public void Clear_dropsPendingMoves_soRecycledGuidIsntMisRolledBack() // I2: pending must not survive a table flush
|
|
{
|
|
var table = new ClientObjectTable();
|
|
table.Ingest(FullWeenie(0x911u, container: 0xC0u));
|
|
table.MoveItemOptimistic(0x911u, 0xC1u, 0); // pending snapshot for 0x911
|
|
table.Clear(); // teleport/logoff flushes the table
|
|
|
|
table.Ingest(FullWeenie(0x911u, container: 0xC5u)); // a recycled guid reappears fresh
|
|
Assert.False(table.RollbackMove(0x911u)); // no stale pending → no mis-rollback
|
|
Assert.Equal(0xC5u, table.Get(0x911u)!.ContainerId);
|
|
}
|
|
|
|
[Fact]
|
|
public void MoveItemOptimistic_insertBefore_shiftsOthers_keepsGaplessOrder() // the "items change position" bug
|
|
{
|
|
var table = new ClientObjectTable();
|
|
table.Ingest(FullWeenie(0x920u, container: 0xC0u)); table.MoveItem(0x920u, 0xC0u, 0);
|
|
table.Ingest(FullWeenie(0x921u, container: 0xC0u)); table.MoveItem(0x921u, 0xC0u, 1);
|
|
table.Ingest(FullWeenie(0x922u, container: 0xC0u)); table.MoveItem(0x922u, 0xC0u, 2);
|
|
|
|
// Move 0x922 (index 2) to index 0 → order [0x922, 0x920, 0x921], slots renumbered 0,1,2 (no ties,
|
|
// no reshuffle). A raw slot-set would leave 0x922 + 0x920 both at slot 0 → unstable sort.
|
|
table.MoveItemOptimistic(0x922u, 0xC0u, 0);
|
|
Assert.Equal(new[] { 0x922u, 0x920u, 0x921u }, table.GetContents(0xC0u));
|
|
Assert.Equal(0, table.Get(0x922u)!.ContainerSlot);
|
|
Assert.Equal(1, table.Get(0x920u)!.ContainerSlot);
|
|
Assert.Equal(2, table.Get(0x921u)!.ContainerSlot);
|
|
}
|
|
|
|
[Fact]
|
|
public void ContainerIndex_SlotChange_ResortsInPlace() // guards the Reindex same-container early-out
|
|
{
|
|
var table = new ClientObjectTable();
|
|
table.Ingest(FullWeenie(0x540u, container: 0xC4u));
|
|
table.Ingest(FullWeenie(0x541u, container: 0xC4u));
|
|
table.MoveItem(0x540u, 0xC4u, newSlot: 0);
|
|
table.MoveItem(0x541u, 0xC4u, newSlot: 1);
|
|
Assert.Equal(new[] { 0x540u, 0x541u }, table.GetContents(0xC4u));
|
|
// move 0x540 to a later slot WITHIN THE SAME container — order must flip
|
|
table.MoveItem(0x540u, 0xC4u, newSlot: 5);
|
|
Assert.Equal(new[] { 0x541u, 0x540u }, table.GetContents(0xC4u));
|
|
Assert.Equal(2, table.GetContents(0xC4u).Count); // no duplicate from the same-container move
|
|
}
|
|
|
|
[Fact]
|
|
public void WieldItemOptimistic_equipsInstantly_setsContainerAndEquip()
|
|
{
|
|
var table = new ClientObjectTable();
|
|
const uint player = 0x50000001u, pack = 0x40000005u;
|
|
table.AddOrUpdate(new ClientObject { ObjectId = 0x940u });
|
|
table.MoveItem(0x940u, pack, newSlot: 2); // a pack item (WielderId stays 0)
|
|
table.WieldItemOptimistic(0x940u, player, EquipMask.HeadWear);
|
|
var o = table.Get(0x940u)!;
|
|
Assert.Equal(EquipMask.HeadWear, o.CurrentlyEquippedLocation); // equipped now (instant)
|
|
Assert.Equal(player, o.ContainerId); // contained-by-wielder (matches the WieldObject 0x0023 confirm)
|
|
Assert.Equal(0u, o.WielderId); // optimistic wield does NOT write WielderId (ContainerId-based model)
|
|
}
|
|
|
|
[Fact]
|
|
public void WieldItemOptimistic_rollback_unequips_andReturnsToPack()
|
|
{
|
|
var table = new ClientObjectTable();
|
|
const uint player = 0x50000001u, pack = 0x40000005u;
|
|
table.AddOrUpdate(new ClientObject { ObjectId = 0x941u });
|
|
table.MoveItem(0x941u, pack, newSlot: 2);
|
|
table.WieldItemOptimistic(0x941u, player, EquipMask.HeadWear);
|
|
Assert.True(table.RollbackMove(0x941u)); // server rejected the wield
|
|
var o = table.Get(0x941u)!;
|
|
Assert.Equal(EquipMask.None, o.CurrentlyEquippedLocation); // un-equipped
|
|
Assert.Equal(pack, o.ContainerId); // back in the pack
|
|
Assert.Equal(2, o.ContainerSlot); // at its original slot
|
|
}
|
|
|
|
[Fact]
|
|
public void RollbackMove_restoresPreMoveEquipLocation() // unwield-reject must snap back to EQUIPPED
|
|
{
|
|
var table = new ClientObjectTable();
|
|
const uint player = 0x50000001u;
|
|
ClientObject? rolledBack = null;
|
|
table.MoveRolledBack += item => rolledBack = item;
|
|
table.AddOrUpdate(new ClientObject { ObjectId = 0x942u });
|
|
table.MoveItem(0x942u, player, newSlot: -1, newEquipLocation: EquipMask.Shield); // equipped
|
|
table.MoveItemOptimistic(0x942u, player, 0); // optimistic UNWIELD into the pack (clears equip)
|
|
Assert.Equal(EquipMask.None, table.Get(0x942u)!.CurrentlyEquippedLocation);
|
|
Assert.True(table.RollbackMove(0x942u)); // server rejected the unwield
|
|
Assert.Equal(EquipMask.Shield, table.Get(0x942u)!.CurrentlyEquippedLocation); // restored to equipped
|
|
Assert.Same(table.Get(0x942u), rolledBack);
|
|
}
|
|
|
|
[Fact]
|
|
public void WieldItemOptimistic_unknownItem_false()
|
|
=> Assert.False(new ClientObjectTable().WieldItemOptimistic(0xDEADu, 0x1u, EquipMask.HeadWear));
|
|
|
|
[Fact]
|
|
public void RejectMove_withoutOptimisticMutation_stillPublishesServerFailure()
|
|
{
|
|
var table = new ClientObjectTable();
|
|
MoveRequestFailure? seen = null;
|
|
table.MoveRequestFailed += failure => seen = failure;
|
|
|
|
Assert.False(table.RejectMove(0xDEADu, 0x04FFu));
|
|
|
|
Assert.Equal(
|
|
new MoveRequestFailure(0xDEADu, 0x04FFu, RolledBack: false),
|
|
seen);
|
|
}
|
|
|
|
[Fact]
|
|
public void RejectMove_withOptimisticMutation_rollsBackAndPublishesFailure()
|
|
{
|
|
var table = new ClientObjectTable();
|
|
const uint item = 0x943u, player = 0x50000001u, pack = 0x40000005u;
|
|
table.AddOrUpdate(new ClientObject { ObjectId = item });
|
|
table.MoveItem(item, pack, 2);
|
|
table.WieldItemOptimistic(item, player, EquipMask.MeleeWeapon);
|
|
MoveRequestFailure? seen = null;
|
|
table.MoveRequestFailed += failure => seen = failure;
|
|
|
|
Assert.True(table.RejectMove(item, 0x04FFu));
|
|
|
|
Assert.Equal(pack, table.Get(item)!.ContainerId);
|
|
Assert.Equal(EquipMask.None, table.Get(item)!.CurrentlyEquippedLocation);
|
|
Assert.Equal(
|
|
new MoveRequestFailure(item, 0x04FFu, RolledBack: true),
|
|
seen);
|
|
}
|
|
|
|
[Fact]
|
|
public void ConfirmMove_publishesOnlyAfterFinalOutstandingRequest()
|
|
{
|
|
var table = new ClientObjectTable();
|
|
const uint item = 0x944u, player = 0x50000001u, pack = 0x40000005u;
|
|
table.AddOrUpdate(new ClientObject { ObjectId = item });
|
|
table.MoveItem(item, pack, 2);
|
|
table.WieldItemOptimistic(item, player, EquipMask.MeleeWeapon);
|
|
table.MoveItemOptimistic(item, pack, 0);
|
|
var confirmed = new List<uint>();
|
|
table.MoveRequestConfirmed += moved => confirmed.Add(moved.ObjectId);
|
|
|
|
table.ConfirmMove(item);
|
|
Assert.Empty(confirmed);
|
|
|
|
table.ConfirmMove(item);
|
|
Assert.Equal(new[] { item }, confirmed);
|
|
}
|
|
|
|
[Fact]
|
|
public void WieldThenMove_oneConfirm_rollsBackToPreWield() // outstanding-count across wield+move (shared RecordPending)
|
|
{
|
|
var table = new ClientObjectTable();
|
|
const uint player = 0x50000001u, pack = 0x40000005u, pack2 = 0x40000006u;
|
|
table.AddOrUpdate(new ClientObject { ObjectId = 0x950u });
|
|
table.MoveItem(0x950u, pack, newSlot: 2); // pre-wield: pack slot 2
|
|
table.WieldItemOptimistic(0x950u, player, EquipMask.HeadWear); // outstanding 1, snapshot (pack, 2, None)
|
|
table.MoveItemOptimistic(0x950u, pack2, 0); // outstanding 2 (same snapshot)
|
|
table.ConfirmMove(0x950u); // confirm the first → outstanding 1, still pending
|
|
Assert.True(table.RollbackMove(0x950u)); // the second rejected → roll back
|
|
var o = table.Get(0x950u)!;
|
|
Assert.Equal(pack, o.ContainerId); // to the ORIGINAL pre-wield container
|
|
Assert.Equal(2, o.ContainerSlot); // and slot
|
|
Assert.Equal(EquipMask.None, o.CurrentlyEquippedLocation); // pre-wield was un-equipped
|
|
}
|
|
|
|
[Fact]
|
|
public void UpdateInt64Property_setsValueAndFiresUpdated()
|
|
{
|
|
var table = new ClientObjectTable();
|
|
table.AddOrUpdate(new ClientObject { ObjectId = 0x960u });
|
|
int updates = 0;
|
|
table.ObjectUpdated += _ => updates++;
|
|
|
|
Assert.True(table.UpdateInt64Property(0x960u, 2u, 12_345L));
|
|
|
|
Assert.Equal(12_345L, table.Get(0x960u)!.Properties.Int64s[2u]);
|
|
Assert.Equal(1, updates);
|
|
}
|
|
|
|
[Fact]
|
|
public void UpdateInt64Property_unknownObject_false()
|
|
=> Assert.False(new ClientObjectTable().UpdateInt64Property(0xDEADu, 2u, 1L));
|
|
}
|