Add the ClientUISystem ground-object lifecycle, authoritative root and nested ViewContents projections, replacement and close semantics, and the DAT-authored gmExternalContainerUI strip for chests and corpses. Route double-click loot and full or partial drag transfers through the shared retail item policy without optimistic external ownership. Remove the incorrect NoLongerViewingContents behavior from owned side packs and retire AP-106/#196. Release build succeeds and all 5,875 tests pass with five intentional skips. Co-authored-by: OpenAI Codex <codex@openai.com>
1240 lines
46 KiB
C#
1240 lines
46 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 StopViewingContents_RemovesOnlyTemporaryProjection()
|
|
{
|
|
var repo = new ClientObjectTable();
|
|
ClientObject item = MakeItem(100u);
|
|
item.ContainerId = 77u;
|
|
repo.AddOrUpdate(item);
|
|
repo.ReplaceContents(77u, new uint[] { 100u });
|
|
uint? changed = null;
|
|
repo.ContainerContentsReplaced += id => changed = id;
|
|
|
|
Assert.True(repo.StopViewingContents(77u));
|
|
|
|
Assert.Empty(repo.GetContents(77u));
|
|
Assert.Same(item, repo.Get(100u));
|
|
Assert.Equal(77u, item.ContainerId);
|
|
Assert.Equal(77u, changed);
|
|
Assert.False(repo.StopViewingContents(77u));
|
|
}
|
|
|
|
[Fact]
|
|
public void StopViewingContentsTree_RemovesNestedSnapshotsButPreservesObjects()
|
|
{
|
|
var repo = new ClientObjectTable();
|
|
repo.ReplaceContents(77u, new[] { new ContainerContentEntry(88u, 1u) });
|
|
repo.ReplaceContents(88u, new[] { new ContainerContentEntry(100u, 0u) });
|
|
|
|
Assert.Equal(2, repo.StopViewingContentsTree(77u));
|
|
|
|
Assert.Empty(repo.GetContents(77u));
|
|
Assert.Empty(repo.GetContents(88u));
|
|
Assert.NotNull(repo.Get(88u));
|
|
Assert.NotNull(repo.Get(100u));
|
|
}
|
|
|
|
[Fact]
|
|
public void MoveItem_UpdatesContainerAndFiresEvent()
|
|
{
|
|
var repo = new ClientObjectTable();
|
|
var item = MakeItem(100);
|
|
repo.AddOrUpdate(item);
|
|
|
|
uint seenOld = 999, seenNew = 999;
|
|
repo.ObjectMoved += move =>
|
|
{
|
|
seenOld = move.Previous.ContainerId;
|
|
seenNew = move.Current.ContainerId;
|
|
};
|
|
|
|
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 ApplyServerMove_PublishesCompleteOldAndNewRetailPlacements()
|
|
{
|
|
var repo = new ClientObjectTable();
|
|
const uint player = 0x50000001u, pack = 0x40000005u, itemId = 101u;
|
|
repo.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = itemId,
|
|
WielderId = player,
|
|
CurrentlyEquippedLocation = EquipMask.MissileWeapon,
|
|
});
|
|
ClientObjectMove? seen = null;
|
|
repo.ObjectMoved += move => seen = move;
|
|
|
|
Assert.True(repo.ApplyServerMove(itemId, pack, 0u, 3));
|
|
|
|
Assert.Equal(
|
|
new ClientObjectPlacement(0u, -1, player, EquipMask.MissileWeapon),
|
|
seen!.Value.Previous);
|
|
Assert.Equal(
|
|
new ClientObjectPlacement(pack, 3, 0u, EquipMask.None),
|
|
seen.Value.Current);
|
|
}
|
|
|
|
[Fact]
|
|
public void ConfirmedUnknownMove_PublishesGuidAndRawPlacement()
|
|
{
|
|
var repo = new ClientObjectTable();
|
|
ClientObjectMove? seen = null;
|
|
repo.ObjectMoved += move => seen = move;
|
|
|
|
Assert.False(repo.ApplyConfirmedServerMove(
|
|
0xDEADu,
|
|
0x40000001u,
|
|
newWielderId: 0u,
|
|
newSlot: 7));
|
|
|
|
Assert.Equal(0xDEADu, seen!.Value.ItemId);
|
|
Assert.Null(seen.Value.Item);
|
|
Assert.Equal(default, seen.Value.Previous);
|
|
Assert.Equal(
|
|
new ClientObjectPlacement(0x40000001u, 7, 0u, EquipMask.None),
|
|
seen.Value.Current);
|
|
}
|
|
|
|
[Fact]
|
|
public void ConfirmedUnknownWield_PublishesGuidPlacementAndCompletion()
|
|
{
|
|
var repo = new ClientObjectTable();
|
|
const uint itemId = 0xBEEFu, player = 0x50000001u;
|
|
ClientObjectMove? seen = null;
|
|
uint completed = 0u;
|
|
repo.ObjectMoved += move => seen = move;
|
|
repo.WieldConfirmed += id => completed = id;
|
|
|
|
Assert.False(repo.ApplyConfirmedServerWield(
|
|
itemId,
|
|
player,
|
|
EquipMask.MissileWeapon));
|
|
|
|
Assert.Equal(itemId, seen!.Value.ItemId);
|
|
Assert.Null(seen.Value.Item);
|
|
Assert.Equal(
|
|
new ClientObjectPlacement(0u, 0, player, EquipMask.MissileWeapon),
|
|
seen.Value.Current);
|
|
Assert.Equal(itemId, completed);
|
|
}
|
|
|
|
[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 RecordMembership_OwnedEquip_UsesSameIndexShapeAsLiveWield()
|
|
{
|
|
const uint playerGuid = 0x50000001u;
|
|
const uint weaponGuid = 0x500000B9u;
|
|
var table = new ClientObjectTable();
|
|
|
|
table.RecordMembership(
|
|
weaponGuid,
|
|
containerId: playerGuid,
|
|
equip: EquipMask.MissileWeapon,
|
|
priority: 7u);
|
|
|
|
var weapon = table.Get(weaponGuid);
|
|
Assert.NotNull(weapon);
|
|
Assert.Equal(playerGuid, weapon!.ContainerId);
|
|
Assert.Equal(-1, weapon.ContainerSlot);
|
|
Assert.Equal(EquipMask.MissileWeapon, weapon.CurrentlyEquippedLocation);
|
|
Assert.Equal(7u, weapon.Priority);
|
|
Assert.Equal(new[] { weaponGuid }, table.GetContents(playerGuid));
|
|
}
|
|
|
|
[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(0u, table.Get(0xA01u)!.ContainerId);
|
|
}
|
|
|
|
[Fact]
|
|
public void ReplaceContents_PublishesListReplacementWithoutSyntheticMove()
|
|
{
|
|
var table = new ClientObjectTable();
|
|
int moves = 0;
|
|
uint replaced = 0u;
|
|
table.ObjectMoved += _ => moves++;
|
|
table.ContainerContentsReplaced += container => replaced = container;
|
|
|
|
table.ReplaceContents(0xC9u, new uint[] { 0xA01u });
|
|
|
|
Assert.Equal(0, moves);
|
|
Assert.Equal(0xC9u, replaced);
|
|
}
|
|
|
|
[Fact]
|
|
public void ProjectionOnlyMember_AuthoritativeMoveEvictsStaleViewedList()
|
|
{
|
|
var table = new ClientObjectTable();
|
|
const uint chest = 0x40000001u, pack = 0x50000001u, item = 0xA20u;
|
|
table.ReplaceContents(chest, new uint[] { item });
|
|
|
|
Assert.True(table.ApplyServerMove(item, pack, newWielderId: 0u, newSlot: 0));
|
|
|
|
Assert.Empty(table.GetContents(chest));
|
|
Assert.Equal(new[] { item }, table.GetContents(pack));
|
|
}
|
|
|
|
[Fact]
|
|
public void ProjectionEvictionNotification_ReentrantMoveSeesCommittedState()
|
|
{
|
|
var table = new ClientObjectTable();
|
|
const uint chest = 0x40000001u, packA = 0x50000001u;
|
|
const uint packB = 0x50000002u, item = 0xA22u;
|
|
table.ReplaceContents(chest, new uint[] { item });
|
|
table.ContainerContentsReplaced += containerId =>
|
|
{
|
|
if (containerId == chest)
|
|
Assert.True(table.ApplyServerMove(item, packB, newWielderId: 0u, newSlot: 0));
|
|
};
|
|
|
|
Assert.True(table.ApplyServerMove(item, packA, newWielderId: 0u, newSlot: 0));
|
|
|
|
Assert.Empty(table.GetContents(chest));
|
|
Assert.Empty(table.GetContents(packA));
|
|
Assert.Equal(new[] { item }, table.GetContents(packB));
|
|
Assert.Equal(packB, table.Get(item)!.ContainerId);
|
|
}
|
|
|
|
[Fact]
|
|
public void ProjectionOnlyMember_DeleteEvictsStaleViewedListAndNotifies()
|
|
{
|
|
var table = new ClientObjectTable();
|
|
const uint chest = 0x40000001u, item = 0xA21u;
|
|
table.ReplaceContents(chest, new uint[] { item });
|
|
int replacements = 0;
|
|
table.ContainerContentsReplaced += id =>
|
|
{
|
|
if (id == chest) replacements++;
|
|
};
|
|
|
|
Assert.True(table.Remove(item));
|
|
|
|
Assert.Empty(table.GetContents(chest));
|
|
Assert.Equal(1, replacements);
|
|
}
|
|
|
|
[Fact]
|
|
public void DeleteProjectionNotification_GuidReuseSurvivesCompletedTeardown()
|
|
{
|
|
var table = new ClientObjectTable();
|
|
const uint chest = 0x40000001u, item = 0xA23u, player = 0x50000001u;
|
|
table.ReplaceContents(chest, new uint[] { item });
|
|
table.ContainerContentsReplaced += containerId =>
|
|
{
|
|
if (containerId != chest) return;
|
|
table.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = item,
|
|
WielderId = player,
|
|
CurrentlyEquippedLocation = EquipMask.MissileWeapon,
|
|
});
|
|
};
|
|
|
|
Assert.True(table.Remove(item));
|
|
|
|
Assert.NotNull(table.Get(item));
|
|
Assert.Equal(
|
|
new[] { item },
|
|
table.GetEquippedBy(player).Select(equipped => equipped.ObjectId));
|
|
}
|
|
|
|
[Fact]
|
|
public void ReplaceContents_SecondSmallerList_ReplacesProjectionOnly()
|
|
{
|
|
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);
|
|
Assert.Equal(0u, table.Get(0xA03u)!.ContainerId);
|
|
Assert.Equal(0u, table.Get(0xA02u)!.ContainerId);
|
|
}
|
|
|
|
[Fact]
|
|
public void ReplaceContents_listedMemberPreservesCanonicalPlacement()
|
|
{
|
|
var table = new ClientObjectTable();
|
|
table.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = 0xA10u,
|
|
ContainerId = 0u,
|
|
WielderId = 0x50000001u,
|
|
CurrentlyEquippedLocation = EquipMask.ChestWear | EquipMask.UpperArmWear,
|
|
});
|
|
|
|
table.ReplaceContents(0x50000001u, new uint[] { 0xA10u });
|
|
|
|
var item = table.Get(0xA10u)!;
|
|
Assert.Equal(0u, item.ContainerId);
|
|
Assert.Equal(0x50000001u, item.WielderId);
|
|
Assert.Equal(-1, item.ContainerSlot);
|
|
Assert.Equal(
|
|
EquipMask.ChestWear | EquipMask.UpperArmWear,
|
|
item.CurrentlyEquippedLocation);
|
|
}
|
|
|
|
[Fact]
|
|
public void ReplaceContents_omittedMemberPreservesCanonicalPlacement()
|
|
{
|
|
var table = new ClientObjectTable();
|
|
table.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = 0xA11u,
|
|
WielderId = 0x50000001u,
|
|
});
|
|
table.MoveItem(0xA11u, 0x50000001u, newSlot: 0,
|
|
newEquipLocation: EquipMask.ChestWear | EquipMask.UpperArmWear);
|
|
|
|
table.ReplaceContents(0x50000001u, Array.Empty<uint>());
|
|
|
|
var item = table.Get(0xA11u)!;
|
|
Assert.Equal(0x50000001u, item.ContainerId);
|
|
Assert.Equal(0x50000001u, item.WielderId);
|
|
Assert.Equal(
|
|
EquipMask.ChestWear | EquipMask.UpperArmWear,
|
|
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(-1, table.Get(0xA01u)!.ContainerSlot);
|
|
Assert.Equal(-1, table.Get(0xA02u)!.ContainerSlot);
|
|
Assert.Equal(1u, table.Get(0xA02u)!.ContainerTypeHint);
|
|
}
|
|
|
|
[Fact]
|
|
public void InitializeInventoryManifest_SeedsCanonicalOwnershipAndOrder()
|
|
{
|
|
var table = new ClientObjectTable();
|
|
const uint player = 0x50000001u;
|
|
|
|
table.InitializeInventoryManifest(player, new[]
|
|
{
|
|
new ContainerContentEntry(0xA01u, 0u),
|
|
new ContainerContentEntry(0xA02u, 1u),
|
|
});
|
|
|
|
Assert.Equal(new[] { 0xA01u, 0xA02u }, table.GetContents(player));
|
|
Assert.Equal(player, table.Get(0xA01u)!.ContainerId);
|
|
Assert.Equal(0, table.Get(0xA01u)!.ContainerSlot);
|
|
Assert.Equal(player, table.Get(0xA02u)!.ContainerId);
|
|
Assert.Equal(1, table.Get(0xA02u)!.ContainerSlot);
|
|
}
|
|
|
|
[Fact]
|
|
public void InitializeEquipmentManifest_PreservesPackedOrderAndCanonicalOwnership()
|
|
{
|
|
var table = new ClientObjectTable();
|
|
const uint player = 0x50000001u;
|
|
|
|
table.InitializeEquipmentManifest(player, new[]
|
|
{
|
|
new EquipmentManifestEntry(0xA11u, EquipMask.MeleeWeapon, 1u),
|
|
new EquipmentManifestEntry(0xA12u, EquipMask.MissileAmmo, 2u),
|
|
});
|
|
|
|
Assert.Equal(
|
|
new[] { 0xA11u, 0xA12u },
|
|
table.GetEquippedBy(player).Select(item => item.ObjectId));
|
|
Assert.Equal(0u, table.Get(0xA11u)!.ContainerId);
|
|
Assert.Equal(player, table.Get(0xA11u)!.WielderId);
|
|
Assert.Empty(table.GetContents(player));
|
|
}
|
|
|
|
[Fact]
|
|
public void InitializeEquipmentManifest_ReplacementClearsOmittedCanonicalEquipment()
|
|
{
|
|
var table = new ClientObjectTable();
|
|
const uint player = 0x50000001u;
|
|
const uint weapon = 0xA11u;
|
|
|
|
table.InitializeEquipmentManifest(player, new[]
|
|
{
|
|
new EquipmentManifestEntry(weapon, EquipMask.MeleeWeapon, 1u),
|
|
});
|
|
table.Get(weapon)!.Burden = 10;
|
|
|
|
table.InitializeEquipmentManifest(player, Array.Empty<EquipmentManifestEntry>());
|
|
|
|
ClientObject item = table.Get(weapon)!;
|
|
Assert.Equal(0u, item.WielderId);
|
|
Assert.Equal(0u, item.ContainerId);
|
|
Assert.Equal(-1, item.ContainerSlot);
|
|
Assert.Equal(EquipMask.None, item.CurrentlyEquippedLocation);
|
|
Assert.Empty(table.GetEquippedBy(player));
|
|
Assert.Equal(0, table.SumCarriedBurden(player));
|
|
}
|
|
|
|
[Fact]
|
|
public void InitializeInventoryManifest_ReplacementClearsOmittedCanonicalOwnership()
|
|
{
|
|
var table = new ClientObjectTable();
|
|
const uint player = 0x50000001u;
|
|
const uint itemId = 0xA01u;
|
|
|
|
table.InitializeInventoryManifest(player, new[]
|
|
{
|
|
new ContainerContentEntry(itemId, 0u),
|
|
});
|
|
table.Get(itemId)!.Burden = 10;
|
|
|
|
table.InitializeInventoryManifest(player, Array.Empty<ContainerContentEntry>());
|
|
|
|
ClientObject item = table.Get(itemId)!;
|
|
Assert.Equal(0u, item.ContainerId);
|
|
Assert.Equal(-1, item.ContainerSlot);
|
|
Assert.Equal(0u, item.WielderId);
|
|
Assert.Equal(EquipMask.None, item.CurrentlyEquippedLocation);
|
|
Assert.Empty(table.GetContents(player));
|
|
Assert.Equal(0, table.SumCarriedBurden(player));
|
|
}
|
|
|
|
[Fact]
|
|
public void PlayerDescriptionManifests_MoveItemBetweenInventoryAndEquipment()
|
|
{
|
|
var table = new ClientObjectTable();
|
|
const uint player = 0x50000001u;
|
|
const uint weapon = 0xA11u;
|
|
|
|
table.InitializeInventoryManifest(player, new[]
|
|
{
|
|
new ContainerContentEntry(weapon, 0u),
|
|
});
|
|
table.InitializeEquipmentManifest(player, new[]
|
|
{
|
|
new EquipmentManifestEntry(weapon, EquipMask.MeleeWeapon, 1u),
|
|
});
|
|
|
|
Assert.Empty(table.GetContents(player));
|
|
Assert.Equal(player, table.Get(weapon)!.WielderId);
|
|
Assert.Equal(EquipMask.MeleeWeapon, table.Get(weapon)!.CurrentlyEquippedLocation);
|
|
|
|
table.InitializeEquipmentManifest(player, Array.Empty<EquipmentManifestEntry>());
|
|
table.InitializeInventoryManifest(player, new[]
|
|
{
|
|
new ContainerContentEntry(weapon, 0u),
|
|
});
|
|
|
|
Assert.Empty(table.GetEquippedBy(player));
|
|
Assert.Equal(new[] { weapon }, table.GetContents(player));
|
|
Assert.Equal(player, table.Get(weapon)!.ContainerId);
|
|
Assert.Equal(0u, table.Get(weapon)!.WielderId);
|
|
}
|
|
|
|
[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); // temporary pre-confirm placement projection
|
|
Assert.Equal(0u, o.WielderId); // authoritative WieldObject later sets WielderId + clears ContainerId
|
|
}
|
|
|
|
[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 ApplyServerMove_UpdatesAuthoritativeWielderAcrossWieldAndUnwield()
|
|
{
|
|
var table = new ClientObjectTable();
|
|
const uint item = 0x9430u, player = 0x50000001u, pack = 0x40000005u;
|
|
table.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = item,
|
|
ContainerId = player,
|
|
WielderId = player,
|
|
CurrentlyEquippedLocation = EquipMask.MissileWeapon,
|
|
});
|
|
|
|
Assert.True(table.ApplyServerMove(item, pack, newWielderId: 0u, newSlot: 2));
|
|
Assert.Equal(pack, table.Get(item)!.ContainerId);
|
|
Assert.Equal(0u, table.Get(item)!.WielderId);
|
|
Assert.Equal(EquipMask.None, table.Get(item)!.CurrentlyEquippedLocation);
|
|
|
|
Assert.True(table.ApplyServerMove(
|
|
item,
|
|
newContainerId: 0u,
|
|
newWielderId: player,
|
|
newEquipLocation: EquipMask.MissileWeapon));
|
|
Assert.Equal(0u, table.Get(item)!.ContainerId);
|
|
Assert.Equal(player, table.Get(item)!.WielderId);
|
|
Assert.Equal(EquipMask.MissileWeapon, table.Get(item)!.CurrentlyEquippedLocation);
|
|
}
|
|
|
|
[Fact]
|
|
public void ApplyConfirmedServerWield_ReentrantMoveKeepsNewPendingRequest()
|
|
{
|
|
var table = new ClientObjectTable();
|
|
const uint item = 0x9431u, player = 0x50000001u, pack = 0x40000005u;
|
|
table.AddOrUpdate(new ClientObject { ObjectId = item, ContainerId = pack, ContainerSlot = 0 });
|
|
Assert.True(table.WieldItemOptimistic(item, player, EquipMask.MissileWeapon));
|
|
|
|
bool queuedFromNotification = false;
|
|
table.ObjectMoved += move =>
|
|
{
|
|
if (queuedFromNotification || move.Current.WielderId != player) return;
|
|
queuedFromNotification = true;
|
|
Assert.True(table.MoveItemOptimistic(item, pack, newSlot: 0));
|
|
};
|
|
|
|
Assert.True(table.ApplyConfirmedServerWield(item, player, EquipMask.MissileWeapon));
|
|
Assert.True(queuedFromNotification);
|
|
Assert.True(table.RollbackMove(item));
|
|
Assert.Equal(0u, table.Get(item)!.ContainerId);
|
|
Assert.Equal(player, table.Get(item)!.WielderId);
|
|
Assert.Equal(EquipMask.MissileWeapon, table.Get(item)!.CurrentlyEquippedLocation);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetEquippedBy_PreservesRetailHeadInsertionOrder()
|
|
{
|
|
var table = new ClientObjectTable();
|
|
const uint player = 0x50000001u;
|
|
table.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = 0x9432u,
|
|
ContainerId = 0u,
|
|
WielderId = player,
|
|
CurrentlyEquippedLocation = EquipMask.MissileWeapon,
|
|
});
|
|
table.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = 0x9433u,
|
|
ContainerId = player,
|
|
WielderId = 0u,
|
|
CurrentlyEquippedLocation = EquipMask.MissileAmmo,
|
|
});
|
|
table.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = 0x9434u,
|
|
ContainerId = player,
|
|
CurrentlyEquippedLocation = EquipMask.None,
|
|
});
|
|
|
|
Assert.Equal(
|
|
new[] { 0x9433u, 0x9432u },
|
|
table.GetEquippedBy(player).Select(item => item.ObjectId));
|
|
}
|
|
|
|
[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 ApplyConfirmedServerWield_publishesIndependentlyOfRollbackOutstandingCount()
|
|
{
|
|
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.WieldConfirmed += itemId => confirmed.Add(itemId);
|
|
|
|
Assert.True(table.ApplyConfirmedServerWield(
|
|
item,
|
|
player,
|
|
EquipMask.MeleeWeapon));
|
|
|
|
Assert.Equal(new[] { item }, confirmed);
|
|
Assert.Equal(0u, table.Get(item)!.ContainerId);
|
|
Assert.Equal(player, table.Get(item)!.WielderId);
|
|
Assert.True(table.RollbackMove(item));
|
|
Assert.Equal(pack, table.Get(item)!.ContainerId);
|
|
Assert.Equal(0u, table.Get(item)!.WielderId);
|
|
Assert.Equal(EquipMask.None, table.Get(item)!.CurrentlyEquippedLocation);
|
|
}
|
|
|
|
[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));
|
|
}
|