The user typed @pklite and then walked straight through other PKLite players. Root cause: ClientObject.PublicWeenieBitfield was written exactly once, from the 0xF745 CreateObject parse, and never refreshed. ACE's only PK-change message is PropertyInt.PlayerKillerStatus (134) over 0x02CE/0x02CD, which we parsed and stored into Properties.Ints[134] but never translated back into the bitfield — and ACE never re-sends a PublicWeenieDesc at all (EnqueueBroadcastUpdateObject has zero live callers), so that property is the ONLY signal a client can learn from. Both sides of the collision test read the frozen value, so CollisionExemption's "4c. both PKLite -> collide" rule could never fire. Retail's missing port: PublicWeenieDesc::SetPlayerKillerStatus @0x005AC7C0 rewrites _bitfield in place — PK(4) -> (b & 0xfddfffff) | 0x20; PKLite(0x40) -> (b & 0xffdfffdf) | 0x2000000; Free(0x20) -> (b & 0xfdffffdf) | 0x200000; else b &= 0xfddfffdf. Mutually exclusive, verified byte-for-byte, with input values confirmed against retail's own PKStatusEnum (acclient.h:6412-6427), not just ACE's. Driven from ACCWeenieObject::OnStatUpdated @0x0058DF20 case 0x86. The fix rewrites the value at its source rather than patching consumers. Two review rounds were needed because the first pass missed that there are TWO snapshot stores: InboundPhysicsStateController keeps its own private _snapshots dictionary, and every untimestamped-field merge (ApplyAcceptedObjDesc and friends) reads `old` from THAT store, not from RuntimeEntityRecord.Snapshot. Refreshing only the active record left the target-side shadow flags correct until the remote's next equip or unequip — ACE broadcasts an ObjDesc on every one — at which point the appearance path rebuilt the registration from the frozen spawn and dropped the bit permanently. The regression test demanded by review is what surfaced that; it is verified discriminating (reverting gives Actual: 8 instead of 33554440). Five stores now hold this value, kept coherent from one source by two ObjectUpdated subscribers plus the appearance-rebuild path. The two shadow-flag writers are the same invalidation applied at the two edges that can invalidate it, not competing authorities — review enumerated every drift path and closed each. That coherence invariant is new as of this commit and is recorded as register row AP-134, with AP-133 as the precedent for filing a row when the danger is a future writer rather than current behaviour. Also corrects TS-23's retirement narrative, which claimed every mover-flags call site read the mover's "real" PK bits from 2026-07-30. The bits existed but their source was frozen, so that only became true here; the site enumeration also missed RuntimeSetPositionMoverPreparation, a seventh site that decodes the snapshot directly. Unblocks #298 (melee/missile admission needs the local player's own PKLite bit). Follow-ups filed: #300 (Properties.Ints[134] vs bitfield mirror gap), #301 (same defect class for radar blip colour and radar behaviour), #302 (a pre-existing PortalProjection allocation-assertion flake, 1 in 6, found while verifying this gate), #303 (LiveEntityPvpBitfieldSync is App-resident but Runtime-owned-state). Gates: complete Release solution 10,895 passed / 4 skipped / 0 failed (baseline 10,887 including #299). Adversarial + retail-conformance review PASS after one FAIL round. Every new test discrimination-verified by reverting the fix. Connected acceptance NOT run — needs a live two-client PKLite session. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1486 lines
55 KiB
C#
1486 lines
55 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 IsOwnedByObjectMatchesRetailDirectPackAndLocationRules()
|
|
{
|
|
const uint player = 0x5000_0001u;
|
|
const uint pack = 0x4000_0001u;
|
|
var repo = new ClientObjectTable();
|
|
repo.AddOrUpdate(MakeItem(player, "Player"));
|
|
repo.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = pack,
|
|
Name = "Pack",
|
|
Type = ItemType.Container,
|
|
ContainerId = player,
|
|
});
|
|
repo.ReplaceContents(
|
|
player,
|
|
[new ContainerContentEntry(pack, ContainerType: 1u)]);
|
|
|
|
ClientObject loose = MakeItem(100u, "Loose");
|
|
loose.ContainerId = player;
|
|
repo.AddOrUpdate(loose);
|
|
|
|
ClientObject packed = MakeItem(101u, "Packed");
|
|
packed.ContainerId = pack;
|
|
repo.AddOrUpdate(packed);
|
|
|
|
ClientObject equipped = MakeItem(102u, "Equipped");
|
|
equipped.WielderId = player;
|
|
equipped.CurrentlyEquippedLocation = EquipMask.MeleeWeapon;
|
|
repo.AddOrUpdate(equipped);
|
|
|
|
ClientObject foreign = MakeItem(103u, "Foreign");
|
|
foreign.ContainerId = 0x6000_0001u;
|
|
repo.AddOrUpdate(foreign);
|
|
|
|
Assert.True(repo.IsOwnedByObject(player, player));
|
|
Assert.True(repo.IsOwnedByObject(loose.ObjectId, player));
|
|
Assert.True(repo.IsOwnedByObject(packed.ObjectId, player));
|
|
Assert.True(repo.IsOwnedByObject(equipped.ObjectId, player));
|
|
Assert.False(repo.IsOwnedByObject(foreign.ObjectId, player));
|
|
Assert.False(repo.IsOwnedByObject(packed.ObjectId, 0u));
|
|
}
|
|
|
|
[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 UpdateProperties_projects_assessed_shared_cooldown_metadata()
|
|
{
|
|
var repo = new ClientObjectTable();
|
|
var item = MakeItem(100);
|
|
repo.AddOrUpdate(item);
|
|
var patch = new PropertyBundle();
|
|
patch.Ints[ClientObjectTable.SharedCooldownPropertyId] = 42;
|
|
patch.Floats[ClientObjectTable.CooldownDurationPropertyId] = 30d;
|
|
|
|
repo.UpdateProperties(100, patch);
|
|
|
|
Assert.Equal(42u, item.CooldownId);
|
|
Assert.Equal(30d, item.CooldownDuration);
|
|
}
|
|
|
|
[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 UpdateIntProperty_playerKillerStatus_setsPublicWeenieBitfield()
|
|
{
|
|
// #297: PropertyInt.PlayerKillerStatus (134) is retail's ONLY live
|
|
// PK-status signal — arriving over either PublicUpdatePropertyInt
|
|
// (0x02CE, remote guid) or PrivateUpdatePropertyInt (0x02CD, self,
|
|
// routed to the player's own guid by ObjectTableWiring). Both routes
|
|
// funnel through this one UpdateIntProperty call, so this pins the
|
|
// shared translation both routes rely on.
|
|
var repo = new ClientObjectTable();
|
|
repo.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = 0x500000AFu,
|
|
PublicWeenieBitfield = 0x8u, // BF_PLAYER only
|
|
});
|
|
ClientObject? fired = null;
|
|
repo.ObjectUpdated += i => fired = i;
|
|
|
|
bool ok = repo.UpdateIntProperty(
|
|
0x500000AFu,
|
|
ClientObjectTable.PlayerKillerStatusPropertyId,
|
|
value: PlayerKillerStatusBitfield.PkLite);
|
|
|
|
Assert.True(ok);
|
|
Assert.Equal(0x2000008u, repo.Get(0x500000AFu)!.PublicWeenieBitfield);
|
|
Assert.Equal(
|
|
PlayerKillerStatusBitfield.PkLite,
|
|
repo.Get(0x500000AFu)!.Properties.Ints[ClientObjectTable.PlayerKillerStatusPropertyId]);
|
|
Assert.NotNull(fired);
|
|
}
|
|
|
|
[Fact]
|
|
public void UpdateIntProperty_playerKillerStatus_nullBitfield_treatsMissingAsZero()
|
|
{
|
|
// An object whose CreateObject omitted the PWD bitfield entirely
|
|
// (WeenieHeaderFlag absent) must not throw/no-op on the first live
|
|
// PK-status update — retail's own PublicWeenieDesc bitfield starts
|
|
// zero-initialized.
|
|
var repo = new ClientObjectTable();
|
|
repo.AddOrUpdate(new ClientObject { ObjectId = 0x500000B4u });
|
|
|
|
Assert.True(repo.UpdateIntProperty(
|
|
0x500000B4u,
|
|
ClientObjectTable.PlayerKillerStatusPropertyId,
|
|
value: PlayerKillerStatusBitfield.Pk));
|
|
|
|
Assert.Equal(0x20u, repo.Get(0x500000B4u)!.PublicWeenieBitfield);
|
|
}
|
|
|
|
[Fact]
|
|
public void ClientObject_NewFields_DefaultAndSettable()
|
|
{
|
|
var o = new ClientObject
|
|
{
|
|
ObjectId = 1, WielderId = 0x42u, ItemsCapacity = 24, ContainersCapacity = 7,
|
|
HookItemTypes = (uint)ItemType.Container, HookType = 4u,
|
|
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((uint)ItemType.Container, o.HookItemTypes);
|
|
Assert.Equal(4u, o.HookType);
|
|
Assert.True(o.IsHook);
|
|
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 IngestAndLiveProperties_PreserveRetailHookIdentity()
|
|
{
|
|
const uint guid = 0x500000AEu;
|
|
var table = new ClientObjectTable();
|
|
ClientObject obj = table.Ingest(
|
|
FullWeenie(guid) with
|
|
{
|
|
HookItemTypes = (uint)ItemType.Container,
|
|
HookType = 4u,
|
|
});
|
|
|
|
Assert.True(obj.IsHook);
|
|
Assert.Equal((uint)ItemType.Container, obj.HookItemTypes);
|
|
Assert.Equal(4u, obj.HookType);
|
|
|
|
Assert.True(table.UpdateIntProperty(
|
|
guid,
|
|
ClientObjectTable.HookTypePropertyId,
|
|
value: 0));
|
|
Assert.False(obj.IsHook);
|
|
Assert.True(table.UpdateIntProperty(
|
|
guid,
|
|
ClientObjectTable.HookTypePropertyId,
|
|
value: 2));
|
|
Assert.True(table.UpdateIntProperty(
|
|
guid,
|
|
ClientObjectTable.HookItemTypesPropertyId,
|
|
value: (int)ItemType.Misc));
|
|
Assert.True(obj.IsHook);
|
|
Assert.Equal(2u, obj.HookType);
|
|
Assert.Equal((uint)ItemType.Misc, obj.HookItemTypes);
|
|
}
|
|
|
|
[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_MaterialType_PreservesWireValueWithoutClobberingWhenAbsent()
|
|
{
|
|
var table = new ClientObjectTable();
|
|
table.Ingest(FullWeenie(0x500000BCu) with { MaterialType = 77u });
|
|
table.Ingest(FullWeenie(0x500000BCu) with { MaterialType = null });
|
|
|
|
Assert.Equal(77u, table.Get(0x500000BCu)!.MaterialType);
|
|
}
|
|
|
|
[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 AuthoritativePickup_PlacementZeroInsertsAtRetailListHead()
|
|
{
|
|
var table = new ClientObjectTable();
|
|
const uint corpse = 0x40000001u;
|
|
const uint pack = 0x50000001u;
|
|
const uint existingA = 0xA01u;
|
|
const uint existingB = 0xA02u;
|
|
const uint firstLoot = 0xA03u;
|
|
const uint secondLoot = 0xA04u;
|
|
|
|
table.InitializeInventoryManifest(pack, new[]
|
|
{
|
|
new ContainerContentEntry(existingA, 0u),
|
|
new ContainerContentEntry(existingB, 0u),
|
|
});
|
|
table.ReplaceContents(corpse, new[] { firstLoot, secondLoot });
|
|
|
|
Assert.True(table.ApplyConfirmedServerMove(
|
|
firstLoot,
|
|
pack,
|
|
newWielderId: 0u,
|
|
newSlot: 0,
|
|
containerTypeHint: 0u));
|
|
Assert.Equal(
|
|
new[] { firstLoot, existingA, existingB },
|
|
table.GetContents(pack));
|
|
Assert.Equal(
|
|
new[] { 0, 1, 2 },
|
|
table.GetContents(pack).Select(id => table.Get(id)!.ContainerSlot));
|
|
|
|
Assert.True(table.ApplyConfirmedServerMove(
|
|
secondLoot,
|
|
pack,
|
|
newWielderId: 0u,
|
|
newSlot: 0,
|
|
containerTypeHint: 0u));
|
|
Assert.Equal(
|
|
new[] { secondLoot, firstLoot, existingA, existingB },
|
|
table.GetContents(pack));
|
|
Assert.Equal(
|
|
new[] { 0, 1, 2, 3 },
|
|
table.GetContents(pack).Select(id => table.Get(id)!.ContainerSlot));
|
|
Assert.Empty(table.GetContents(corpse));
|
|
}
|
|
|
|
[Fact]
|
|
public void AuthoritativeContainerMove_IndexesOnlyTheRetailContainerList()
|
|
{
|
|
var table = new ClientObjectTable();
|
|
const uint pack = 0x50000001u;
|
|
const uint looseA = 0xA11u;
|
|
const uint looseB = 0xA12u;
|
|
const uint bagA = 0xA21u;
|
|
const uint bagB = 0xA22u;
|
|
|
|
table.InitializeInventoryManifest(pack, new[]
|
|
{
|
|
new ContainerContentEntry(looseA, 0u),
|
|
new ContainerContentEntry(bagA, 1u),
|
|
new ContainerContentEntry(looseB, 0u),
|
|
});
|
|
table.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = bagB,
|
|
Type = ItemType.Container,
|
|
ItemsCapacity = 24,
|
|
});
|
|
|
|
Assert.True(table.ApplyConfirmedServerMove(
|
|
bagB,
|
|
pack,
|
|
newWielderId: 0u,
|
|
newSlot: 0,
|
|
containerTypeHint: 1u));
|
|
|
|
uint[] loose = table.GetContents(pack)
|
|
.Where(id => table.Get(id)!.ContainerTypeHint == 0u
|
|
&& (table.Get(id)!.Type & ItemType.Container) == 0)
|
|
.ToArray();
|
|
uint[] bags = table.GetContents(pack)
|
|
.Where(id => table.Get(id)!.ContainerTypeHint != 0u
|
|
|| (table.Get(id)!.Type & ItemType.Container) != 0)
|
|
.ToArray();
|
|
Assert.Equal(new[] { looseA, looseB }, loose);
|
|
Assert.Equal(new[] { bagB, bagA }, bags);
|
|
Assert.Equal(0, table.Get(bagB)!.ContainerSlot);
|
|
Assert.Equal(1, table.Get(bagA)!.ContainerSlot);
|
|
}
|
|
|
|
[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));
|
|
}
|