Issue stable Runtime identities at canonical registration, publish entity and inventory commits through one generation-stamped synchronous stream, and make graphical adapters borrow the same direct views and events as a no-window host. Preserve exact projection teardown and retail mutation order while removing App-side event reconstruction. Make the hard-recenter ordering fixture independent of the production two-millisecond frame budget so its injected-failure gate is deterministic. Co-authored-by: Codex <codex@openai.com>
795 lines
29 KiB
C#
795 lines
29 KiB
C#
using AcDream.Core.Net;
|
|
using AcDream.Core.Net.Messages;
|
|
using AcDream.Core.Physics;
|
|
using AcDream.Core.Items;
|
|
using AcDream.Runtime.Entities;
|
|
|
|
namespace AcDream.Runtime.Tests.Entities;
|
|
|
|
public sealed class RuntimeEntityObjectLifetimeTests
|
|
{
|
|
[Fact]
|
|
public void Owner_ConstructsOneExactDirectoryAndObjectTablePerInstance()
|
|
{
|
|
var first = new RuntimeEntityObjectLifetime();
|
|
var second = new RuntimeEntityObjectLifetime();
|
|
|
|
Assert.NotSame(first.Entities, second.Entities);
|
|
Assert.NotSame(first.Objects, second.Objects);
|
|
Assert.Same(first.Entities, first.Entities);
|
|
Assert.Same(first.Objects, first.Objects);
|
|
}
|
|
|
|
[Fact]
|
|
public void Owner_ConstructionLoadsNoPresentationOrBackendAssembly()
|
|
{
|
|
_ = new RuntimeEntityObjectLifetime();
|
|
|
|
string[] loaded = AppDomain.CurrentDomain
|
|
.GetAssemblies()
|
|
.Select(assembly => assembly.GetName().Name ?? string.Empty)
|
|
.ToArray();
|
|
Assert.DoesNotContain(loaded, name =>
|
|
name.StartsWith("AcDream.App", StringComparison.Ordinal)
|
|
|| name.StartsWith("AcDream.UI.", StringComparison.Ordinal)
|
|
|| name.StartsWith("Silk.NET", StringComparison.Ordinal)
|
|
|| name.StartsWith("OpenAL", StringComparison.Ordinal)
|
|
|| name.StartsWith("ImGui", StringComparison.Ordinal));
|
|
}
|
|
|
|
[Fact]
|
|
public void SeparateOwners_IsolateConflictingGuidsAndContents()
|
|
{
|
|
const uint guid = 0x70000001u;
|
|
var first = new RuntimeEntityObjectLifetime();
|
|
var second = new RuntimeEntityObjectLifetime();
|
|
RuntimeEntityRecord firstRecord = Accept(first, Spawn(guid, 1, "first"));
|
|
RuntimeEntityRecord secondRecord = Accept(second, Spawn(guid, 9, "second"));
|
|
|
|
Assert.True(first.ApplyAcceptedSpawn(
|
|
firstRecord,
|
|
firstRecord.CreateIntegrationVersion,
|
|
firstRecord.Snapshot,
|
|
replaceGeneration: false));
|
|
Assert.True(second.ApplyAcceptedSpawn(
|
|
secondRecord,
|
|
secondRecord.CreateIntegrationVersion,
|
|
secondRecord.Snapshot,
|
|
replaceGeneration: false));
|
|
|
|
Assert.Equal("first", first.Objects.Get(guid)!.Name);
|
|
Assert.Equal("second", second.Objects.Get(guid)!.Name);
|
|
Assert.Equal((ushort)1, firstRecord.Incarnation);
|
|
Assert.Equal((ushort)9, secondRecord.Incarnation);
|
|
}
|
|
|
|
[Fact]
|
|
public void AcceptedSpawn_RejectsWrongOrSupersededCanonicalIncarnation()
|
|
{
|
|
var lifetime = new RuntimeEntityObjectLifetime();
|
|
WorldSession.EntitySpawn spawn = Spawn(0x70000002u, 3, "current");
|
|
RuntimeEntityRecord canonical = Accept(lifetime, spawn);
|
|
var wrong = new RuntimeEntityObjectLifetime();
|
|
RuntimeEntityRecord wrongCanonical = Accept(wrong, spawn);
|
|
|
|
Assert.False(lifetime.ApplyAcceptedSpawn(
|
|
wrongCanonical,
|
|
wrongCanonical.CreateIntegrationVersion,
|
|
spawn,
|
|
replaceGeneration: false));
|
|
|
|
ulong expectedVersion = canonical.CreateIntegrationVersion;
|
|
lifetime.Entities.AdvanceCreateAuthority(canonical);
|
|
Assert.False(lifetime.ApplyAcceptedSpawn(
|
|
canonical,
|
|
expectedVersion,
|
|
spawn,
|
|
replaceGeneration: false));
|
|
Assert.Null(lifetime.Objects.Get(spawn.Guid));
|
|
}
|
|
|
|
[Fact]
|
|
public void AcceptedSpawn_RevalidatesAfterSynchronousObjectCallbacks()
|
|
{
|
|
var lifetime = new RuntimeEntityObjectLifetime();
|
|
WorldSession.EntitySpawn spawn = Spawn(0x70000003u, 4, "reentrant");
|
|
RuntimeEntityRecord canonical = Accept(lifetime, spawn);
|
|
ulong expectedVersion = canonical.CreateIntegrationVersion;
|
|
lifetime.Objects.ObjectAdded += _ =>
|
|
lifetime.Entities.AdvanceCreateAuthority(canonical);
|
|
|
|
Assert.False(lifetime.ApplyAcceptedSpawn(
|
|
canonical,
|
|
expectedVersion,
|
|
spawn,
|
|
replaceGeneration: false));
|
|
Assert.NotNull(lifetime.Objects.Get(spawn.Guid));
|
|
}
|
|
|
|
[Fact]
|
|
public void AcceptedDelete_RemovesOnlyMatchingLogicalGeneration()
|
|
{
|
|
var lifetime = new RuntimeEntityObjectLifetime();
|
|
WorldSession.EntitySpawn spawn = Spawn(0x70000004u, 6, "retained");
|
|
RuntimeEntityRecord canonical = Accept(lifetime, spawn);
|
|
Assert.True(lifetime.ApplyAcceptedSpawn(
|
|
canonical,
|
|
canonical.CreateIntegrationVersion,
|
|
spawn,
|
|
replaceGeneration: true));
|
|
|
|
Assert.False(lifetime.TryAcceptDelete(
|
|
new DeleteObject.Parsed(spawn.Guid, 7),
|
|
isLocalPlayer: false,
|
|
removeRetainedObject: true,
|
|
out _));
|
|
Assert.NotNull(lifetime.Objects.Get(spawn.Guid));
|
|
|
|
Assert.True(lifetime.TryAcceptDelete(
|
|
new DeleteObject.Parsed(spawn.Guid, 6),
|
|
isLocalPlayer: false,
|
|
removeRetainedObject: true,
|
|
out RuntimeEntityDeleteAcceptance accepted));
|
|
Assert.Same(canonical, accepted.RetiredCanonical);
|
|
Assert.False(lifetime.Entities.TryGetActive(spawn.Guid, out _));
|
|
lifetime.CompleteAcceptedDelete(accepted);
|
|
Assert.Null(lifetime.RetireCanonicalOnly(canonical));
|
|
Assert.Null(lifetime.Objects.Get(spawn.Guid));
|
|
}
|
|
|
|
[Fact]
|
|
public void DeleteAcceptance_CannotCrossOwnersOrReplay()
|
|
{
|
|
var owner = new RuntimeEntityObjectLifetime();
|
|
var other = new RuntimeEntityObjectLifetime();
|
|
WorldSession.EntitySpawn spawn = Spawn(0x70000006u, 8, "token");
|
|
RuntimeEntityRecord canonical = Accept(owner, spawn);
|
|
Assert.True(owner.ApplyAcceptedSpawn(
|
|
canonical,
|
|
canonical.CreateIntegrationVersion,
|
|
spawn,
|
|
replaceGeneration: true));
|
|
Assert.True(owner.TryAcceptDelete(
|
|
new DeleteObject.Parsed(spawn.Guid, spawn.InstanceSequence),
|
|
isLocalPlayer: false,
|
|
removeRetainedObject: true,
|
|
out RuntimeEntityDeleteAcceptance acceptance));
|
|
|
|
Assert.Throws<InvalidOperationException>(
|
|
() => other.CompleteAcceptedDelete(acceptance));
|
|
owner.CompleteAcceptedDelete(acceptance);
|
|
Assert.Throws<InvalidOperationException>(
|
|
() => owner.CompleteAcceptedDelete(acceptance));
|
|
Assert.Null(owner.Objects.Get(spawn.Guid));
|
|
}
|
|
|
|
[Fact]
|
|
public void ClearObjects_DoesNotResetCanonicalEntityIdentity()
|
|
{
|
|
var lifetime = new RuntimeEntityObjectLifetime();
|
|
WorldSession.EntitySpawn spawn = Spawn(0x70000005u, 2, "clear");
|
|
RuntimeEntityRecord canonical = Accept(lifetime, spawn);
|
|
Assert.True(lifetime.ApplyAcceptedSpawn(
|
|
canonical,
|
|
canonical.CreateIntegrationVersion,
|
|
spawn,
|
|
replaceGeneration: false));
|
|
|
|
lifetime.ClearObjects();
|
|
|
|
Assert.Null(lifetime.Objects.Get(spawn.Guid));
|
|
Assert.True(lifetime.Entities.TryGetActive(spawn.Guid, out var retained));
|
|
Assert.Same(canonical, retained);
|
|
}
|
|
|
|
[Fact]
|
|
public void CanonicalRegistration_IssuesIdentityBeforeAnyProjection()
|
|
{
|
|
var lifetime = new RuntimeEntityObjectLifetime();
|
|
WorldSession.EntitySpawn spawn = Spawn(
|
|
0x70000010u,
|
|
4,
|
|
"direct");
|
|
RuntimeEntityRecord canonical = Accept(lifetime, spawn);
|
|
|
|
Assert.Equal(
|
|
RuntimeEntityDirectory.FirstLocalEntityId,
|
|
canonical.LocalEntityId);
|
|
Assert.True(lifetime.EntityView.TryGet(
|
|
spawn.Guid,
|
|
out RuntimeEntitySnapshot snapshot));
|
|
Assert.Equal(canonical.LocalEntityId, snapshot.Identity.LocalEntityId);
|
|
Assert.Equal(spawn.InstanceSequence, snapshot.Identity.Incarnation);
|
|
Assert.Equal(spawn.Position!.Value.LandblockId, snapshot.CellId);
|
|
}
|
|
|
|
[Fact]
|
|
public void EntityAndInventoryCommits_ShareOnePerGenerationSequence()
|
|
{
|
|
var lifetime = new RuntimeEntityObjectLifetime();
|
|
RuntimeGenerationToken generation = new(8);
|
|
ulong frame = 20;
|
|
lifetime.BindEventContext(() => generation, () => frame);
|
|
var observer = new RecordingObserver();
|
|
using IDisposable subscription = lifetime.Events.Subscribe(observer);
|
|
WorldSession.EntitySpawn spawn = Spawn(
|
|
0x70000011u,
|
|
2,
|
|
"ordered");
|
|
RuntimeEntityRecord canonical =
|
|
lifetime.RegisterEntity(spawn).Canonical!;
|
|
|
|
Assert.True(lifetime.ApplyAcceptedSpawn(
|
|
canonical,
|
|
canonical.CreateIntegrationVersion,
|
|
spawn,
|
|
replaceGeneration: false));
|
|
frame++;
|
|
lifetime.Objects.MoveItem(
|
|
spawn.Guid,
|
|
newContainerId: 0x50000001u,
|
|
newSlot: 3);
|
|
_ = lifetime.RegisterEntity(spawn);
|
|
|
|
Assert.Equal(
|
|
[
|
|
(RuntimeTraceKind.Entity, 1UL),
|
|
(RuntimeTraceKind.Inventory, 2UL),
|
|
(RuntimeTraceKind.Inventory, 3UL),
|
|
(RuntimeTraceKind.Entity, 4UL),
|
|
],
|
|
observer.Order);
|
|
Assert.All(
|
|
observer.Stamps,
|
|
stamp => Assert.Equal(generation, stamp.Generation));
|
|
Assert.Equal(20UL, observer.Stamps[0].FrameNumber);
|
|
Assert.Equal(21UL, observer.Stamps[^1].FrameNumber);
|
|
|
|
generation = new RuntimeGenerationToken(9);
|
|
lifetime.ClearObjects();
|
|
Assert.Equal(1UL, observer.Stamps[^1].Sequence);
|
|
Assert.Equal(generation, observer.Stamps[^1].Generation);
|
|
}
|
|
|
|
[Fact]
|
|
public void InventoryView_UsesCanonicalRuntimeIncarnationWithoutApp()
|
|
{
|
|
var lifetime = new RuntimeEntityObjectLifetime();
|
|
WorldSession.EntitySpawn spawn = Spawn(
|
|
0x70000012u,
|
|
12,
|
|
"inventory");
|
|
RuntimeEntityRecord canonical = Accept(lifetime, spawn);
|
|
Assert.True(lifetime.ApplyAcceptedSpawn(
|
|
canonical,
|
|
canonical.CreateIntegrationVersion,
|
|
spawn,
|
|
replaceGeneration: false));
|
|
|
|
Assert.True(lifetime.InventoryView.TryGet(
|
|
spawn.Guid,
|
|
out RuntimeInventoryItemSnapshot item));
|
|
Assert.Equal((ushort)12, item.Incarnation);
|
|
Assert.Equal("inventory", item.Name);
|
|
}
|
|
|
|
[Fact]
|
|
public void UnknownConfirmedMove_RetainsIdentityAndPlacementInStream()
|
|
{
|
|
var lifetime = new RuntimeEntityObjectLifetime();
|
|
var observer = new RecordingObserver();
|
|
using IDisposable subscription = lifetime.Events.Subscribe(observer);
|
|
|
|
Assert.False(lifetime.Objects.ApplyConfirmedServerMove(
|
|
0x7000DEADu,
|
|
newContainerId: 0x50000001u,
|
|
newWielderId: 0u,
|
|
newSlot: 7));
|
|
|
|
RuntimeInventoryDelta moved = Assert.Single(observer.Inventory);
|
|
Assert.Equal(RuntimeInventoryChange.Moved, moved.Change);
|
|
Assert.Equal(0x7000DEADu, moved.Item.ObjectId);
|
|
Assert.Equal(0x50000001u, moved.Item.ContainerId);
|
|
Assert.Equal(7, moved.Item.ContainerSlot);
|
|
}
|
|
|
|
[Fact]
|
|
public void EventSubscriptions_AreExactAndDisposeWithoutRetention()
|
|
{
|
|
var lifetime = new RuntimeEntityObjectLifetime();
|
|
var observer = new RecordingObserver();
|
|
IDisposable subscription = lifetime.Events.Subscribe(observer);
|
|
|
|
Assert.Equal(1, lifetime.Events.SubscriberCount);
|
|
Assert.Throws<InvalidOperationException>(
|
|
() => lifetime.Events.Subscribe(observer));
|
|
|
|
subscription.Dispose();
|
|
subscription.Dispose();
|
|
|
|
Assert.Equal(0, lifetime.Events.SubscriberCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void Dispose_DetachesEveryObserverAndRejectsLaterDispatch()
|
|
{
|
|
var lifetime = new RuntimeEntityObjectLifetime();
|
|
var observer = new RecordingObserver();
|
|
_ = lifetime.Events.Subscribe(observer);
|
|
|
|
lifetime.Dispose();
|
|
lifetime.Dispose();
|
|
|
|
Assert.Equal(0, lifetime.Events.SubscriberCount);
|
|
Assert.Throws<ObjectDisposedException>(
|
|
() => lifetime.RegisterEntity(
|
|
Spawn(0x70000018u, 1, "disposed")));
|
|
lifetime.Objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = 0x70000019u,
|
|
Name = "detached",
|
|
});
|
|
Assert.Empty(observer.Order);
|
|
}
|
|
|
|
[Fact]
|
|
public void ObserverFailure_DoesNotInterruptCommitOrLaterObservers()
|
|
{
|
|
var lifetime = new RuntimeEntityObjectLifetime();
|
|
var throwing = new CallbackObserver(
|
|
onEntity: static _ => throw new InvalidOperationException("observer"));
|
|
var recording = new RecordingObserver();
|
|
using IDisposable first = lifetime.Events.Subscribe(throwing);
|
|
using IDisposable second = lifetime.Events.Subscribe(recording);
|
|
|
|
RuntimeEntityRegistrationResult result =
|
|
lifetime.RegisterEntity(Spawn(0x70000015u, 1, "committed"));
|
|
|
|
Assert.True(result.LogicalRegistrationCreated);
|
|
Assert.NotNull(result.Canonical);
|
|
Assert.True(lifetime.Entities.IsCurrent(result.Canonical));
|
|
Assert.Single(recording.Entities);
|
|
Assert.Equal(RuntimeEntityChange.Registered, recording.Entities[0].Change);
|
|
Assert.Equal(1, lifetime.Events.DispatchFailureCount);
|
|
Assert.IsType<InvalidOperationException>(
|
|
lifetime.Events.LastDispatchFailure);
|
|
}
|
|
|
|
[Fact]
|
|
public void ObserverMutationDuringDispatch_AffectsOnlyLaterCommits()
|
|
{
|
|
var lifetime = new RuntimeEntityObjectLifetime();
|
|
var removed = new RecordingObserver();
|
|
var added = new RecordingObserver();
|
|
IDisposable? removedSubscription = null;
|
|
IDisposable? addedSubscription = null;
|
|
bool mutated = false;
|
|
var mutating = new CallbackObserver(
|
|
onEntity: _ =>
|
|
{
|
|
if (mutated)
|
|
return;
|
|
mutated = true;
|
|
removedSubscription!.Dispose();
|
|
addedSubscription = lifetime.Events.Subscribe(added);
|
|
});
|
|
using IDisposable first = lifetime.Events.Subscribe(mutating);
|
|
removedSubscription = lifetime.Events.Subscribe(removed);
|
|
|
|
RuntimeEntityRegistrationResult registration =
|
|
lifetime.RegisterEntity(Spawn(0x70000016u, 1, "first"));
|
|
_ = lifetime.RegisterEntity(registration.Canonical!.Snapshot);
|
|
|
|
Assert.Single(removed.Entities);
|
|
Assert.Single(added.Entities);
|
|
Assert.Equal(
|
|
RuntimeEntityChange.Registered,
|
|
removed.Entities[0].Change);
|
|
Assert.Equal(
|
|
RuntimeEntityChange.Updated,
|
|
added.Entities[0].Change);
|
|
|
|
addedSubscription!.Dispose();
|
|
Assert.Equal(1, lifetime.Events.SubscriberCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void DirectDelete_PublishesTerminalEntityBeforeInventoryRemoval()
|
|
{
|
|
var lifetime = new RuntimeEntityObjectLifetime();
|
|
lifetime.BindEventContext(static () => new(4), static () => 11);
|
|
var observer = new RecordingObserver();
|
|
using IDisposable subscription = lifetime.Events.Subscribe(observer);
|
|
WorldSession.EntitySpawn spawn =
|
|
Spawn(0x70000017u, 5, "delete");
|
|
RuntimeEntityRegistrationResult registration =
|
|
lifetime.RegisterEntity(spawn);
|
|
RuntimeEntityRecord canonical = registration.Canonical!;
|
|
Assert.True(lifetime.ApplyAcceptedSpawn(
|
|
canonical,
|
|
canonical.CreateIntegrationVersion,
|
|
spawn,
|
|
replaceGeneration: true));
|
|
|
|
Assert.True(lifetime.TryAcceptDelete(
|
|
new DeleteObject.Parsed(spawn.Guid, spawn.InstanceSequence),
|
|
isLocalPlayer: false,
|
|
removeRetainedObject: true,
|
|
out RuntimeEntityDeleteAcceptance acceptance));
|
|
lifetime.CompleteAcceptedDelete(acceptance);
|
|
Assert.Null(lifetime.RetireCanonicalOnly(canonical));
|
|
|
|
Assert.Equal(
|
|
[
|
|
(RuntimeTraceKind.Entity, 1UL),
|
|
(RuntimeTraceKind.Inventory, 2UL),
|
|
(RuntimeTraceKind.Entity, 3UL),
|
|
(RuntimeTraceKind.Inventory, 4UL),
|
|
],
|
|
observer.Order);
|
|
Assert.Equal(
|
|
RuntimeEntityChange.Deleted,
|
|
observer.Entities[^1].Change);
|
|
Assert.False(lifetime.EntityView.TryGet(spawn.Guid, out _));
|
|
Assert.False(lifetime.InventoryView.TryGet(spawn.Guid, out _));
|
|
Assert.Equal(0, lifetime.Entities.ClaimedLocalIdCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void SessionClear_SealsOldGenerationAndNextStartsAtOne()
|
|
{
|
|
var lifetime = new RuntimeEntityObjectLifetime();
|
|
RuntimeGenerationToken generation = new(12);
|
|
lifetime.BindEventContext(() => generation, static () => 30UL);
|
|
var observer = new RecordingObserver();
|
|
bool rejectedReentrantCreate = false;
|
|
var reentrant = new CallbackObserver(
|
|
onEntity: delta =>
|
|
{
|
|
if (delta.Change is not RuntimeEntityChange.Deleted)
|
|
return;
|
|
rejectedReentrantCreate = Assert.Throws<InvalidOperationException>(
|
|
() => lifetime.RegisterEntity(
|
|
Spawn(delta.Entity.Identity.ServerGuid, 2, "stale")))
|
|
is not null;
|
|
});
|
|
using IDisposable first = lifetime.Events.Subscribe(reentrant);
|
|
using IDisposable second = lifetime.Events.Subscribe(observer);
|
|
WorldSession.EntitySpawn spawn =
|
|
Spawn(0x70000020u, 1, "old");
|
|
RuntimeEntityRecord canonical =
|
|
lifetime.RegisterEntity(spawn).Canonical!;
|
|
Assert.True(lifetime.ApplyAcceptedSpawn(
|
|
canonical,
|
|
canonical.CreateIntegrationVersion,
|
|
spawn,
|
|
replaceGeneration: true));
|
|
|
|
IReadOnlyList<RuntimeEntityRecord> retired =
|
|
lifetime.BeginSessionClear();
|
|
lifetime.ClearObjects();
|
|
Assert.Null(lifetime.RetireCanonicalOnly(Assert.Single(retired)));
|
|
Assert.True(lifetime.CompleteSessionClearIfConverged());
|
|
|
|
generation = new RuntimeGenerationToken(13);
|
|
RuntimeEntityRecord replacement =
|
|
lifetime.RegisterEntity(Spawn(spawn.Guid, 2, "new")).Canonical!;
|
|
|
|
Assert.True(rejectedReentrantCreate);
|
|
Assert.Equal((ushort)2, replacement.Incarnation);
|
|
Assert.Equal(
|
|
[
|
|
(new RuntimeGenerationToken(12), 1UL),
|
|
(new RuntimeGenerationToken(12), 2UL),
|
|
(new RuntimeGenerationToken(12), 3UL),
|
|
(new RuntimeGenerationToken(12), 4UL),
|
|
(new RuntimeGenerationToken(13), 1UL),
|
|
],
|
|
observer.Stamps
|
|
.Select(stamp => (stamp.Generation, stamp.Sequence))
|
|
.ToArray());
|
|
}
|
|
|
|
[Fact]
|
|
public void RegisterEntity_DirectHostPublishesCompleteGenerationOrder()
|
|
{
|
|
var lifetime = new RuntimeEntityObjectLifetime();
|
|
lifetime.BindEventContext(static () => new(3), static () => 9);
|
|
var observer = new RecordingObserver();
|
|
using IDisposable subscription = lifetime.Events.Subscribe(observer);
|
|
const uint guid = 0x70000013u;
|
|
|
|
RuntimeEntityRegistrationResult first =
|
|
lifetime.RegisterEntity(Spawn(guid, 1, "first"));
|
|
RuntimeEntityRegistrationResult refresh =
|
|
lifetime.RegisterEntity(Spawn(guid, 1, "refresh"));
|
|
RuntimeEntityRegistrationResult replacement =
|
|
lifetime.RegisterEntity(Spawn(guid, 2, "replacement"));
|
|
|
|
Assert.True(first.LogicalRegistrationCreated);
|
|
Assert.False(refresh.LogicalRegistrationCreated);
|
|
Assert.True(replacement.ReplacedExistingGeneration);
|
|
Assert.Equal(
|
|
[
|
|
RuntimeEntityChange.Registered,
|
|
RuntimeEntityChange.Updated,
|
|
RuntimeEntityChange.Deleted,
|
|
RuntimeEntityChange.Registered,
|
|
],
|
|
observer.Entities.Select(delta => delta.Change).ToArray());
|
|
Assert.Equal(
|
|
[1UL, 2UL, 3UL, 4UL],
|
|
observer.Entities
|
|
.Select(delta => delta.Stamp.Sequence)
|
|
.ToArray());
|
|
Assert.Equal(
|
|
(ushort)2,
|
|
lifetime.EntityView.TryGet(guid, out RuntimeEntitySnapshot current)
|
|
? current.Identity.Incarnation
|
|
: (ushort)0);
|
|
Assert.Equal(0, lifetime.Entities.PendingTeardownCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void RegisterEntity_ReentrantNewerGenerationSupersedesOuterCreate()
|
|
{
|
|
var lifetime = new RuntimeEntityObjectLifetime();
|
|
const uint guid = 0x70000014u;
|
|
_ = lifetime.RegisterEntity(Spawn(guid, 1, "first"));
|
|
bool nested = false;
|
|
var observer = new CallbackObserver(
|
|
onEntity: delta =>
|
|
{
|
|
if (nested
|
|
|| delta.Change is not RuntimeEntityChange.Deleted)
|
|
{
|
|
return;
|
|
}
|
|
|
|
nested = true;
|
|
_ = lifetime.RegisterEntity(Spawn(guid, 3, "nested"));
|
|
});
|
|
using IDisposable subscription = lifetime.Events.Subscribe(observer);
|
|
|
|
RuntimeEntityRegistrationResult outer =
|
|
lifetime.RegisterEntity(Spawn(guid, 2, "outer"));
|
|
|
|
Assert.Equal(
|
|
CreateObjectTimestampDisposition.StaleGeneration,
|
|
outer.Inbound.Disposition);
|
|
Assert.False(outer.LogicalRegistrationCreated);
|
|
Assert.True(lifetime.Entities.TryGetActive(
|
|
guid,
|
|
out RuntimeEntityRecord current));
|
|
Assert.Equal((ushort)3, current.Incarnation);
|
|
Assert.NotNull(current.LocalEntityId);
|
|
}
|
|
|
|
[Fact]
|
|
public void RegisterEntity_ReentrantDeleteSupersedesSameGenerationRefresh()
|
|
{
|
|
var lifetime = new RuntimeEntityObjectLifetime();
|
|
const uint guid = 0x70000021u;
|
|
RuntimeEntityRecord first =
|
|
lifetime.RegisterEntity(Spawn(guid, 1, "first")).Canonical!;
|
|
bool deleted = false;
|
|
var observer = new CallbackObserver(
|
|
onEntity: delta =>
|
|
{
|
|
if (deleted
|
|
|| delta.Change is not RuntimeEntityChange.Updated)
|
|
{
|
|
return;
|
|
}
|
|
|
|
deleted = lifetime.TryAcceptDelete(
|
|
new DeleteObject.Parsed(guid, 1),
|
|
isLocalPlayer: false,
|
|
removeRetainedObject: false,
|
|
out RuntimeEntityDeleteAcceptance acceptance);
|
|
if (deleted)
|
|
{
|
|
lifetime.CompleteAcceptedDelete(acceptance);
|
|
Assert.Null(lifetime.RetireCanonicalOnly(first));
|
|
}
|
|
});
|
|
using IDisposable subscription = lifetime.Events.Subscribe(observer);
|
|
|
|
RuntimeEntityRegistrationResult refresh =
|
|
lifetime.RegisterEntity(Spawn(guid, 1, "refresh"));
|
|
|
|
Assert.True(deleted);
|
|
Assert.Equal(
|
|
CreateObjectTimestampDisposition.StaleGeneration,
|
|
refresh.Inbound.Disposition);
|
|
Assert.Null(refresh.Canonical);
|
|
Assert.False(lifetime.Entities.TryGetActive(guid, out _));
|
|
}
|
|
|
|
[Fact]
|
|
public void RegisterEntity_ReentrantReplacementSupersedesInitialCommit()
|
|
{
|
|
var lifetime = new RuntimeEntityObjectLifetime();
|
|
const uint guid = 0x70000022u;
|
|
bool replaced = false;
|
|
var observer = new CallbackObserver(
|
|
onEntity: delta =>
|
|
{
|
|
if (replaced
|
|
|| delta.Change is not RuntimeEntityChange.Registered
|
|
|| delta.Entity.Identity.Incarnation != 1)
|
|
{
|
|
return;
|
|
}
|
|
|
|
replaced = true;
|
|
_ = lifetime.RegisterEntity(Spawn(guid, 2, "replacement"));
|
|
});
|
|
using IDisposable subscription = lifetime.Events.Subscribe(observer);
|
|
|
|
RuntimeEntityRegistrationResult outer =
|
|
lifetime.RegisterEntity(Spawn(guid, 1, "outer"));
|
|
|
|
Assert.True(replaced);
|
|
Assert.Equal(
|
|
CreateObjectTimestampDisposition.StaleGeneration,
|
|
outer.Inbound.Disposition);
|
|
Assert.False(outer.LogicalRegistrationCreated);
|
|
Assert.Equal(
|
|
(ushort)2,
|
|
Assert.IsType<RuntimeEntityRecord>(outer.Canonical).Incarnation);
|
|
}
|
|
|
|
[Fact]
|
|
public void AcceptedUpdate_RevalidatesAfterReentrantTerminalObserver()
|
|
{
|
|
var lifetime = new RuntimeEntityObjectLifetime();
|
|
const uint guid = 0x70000023u;
|
|
RuntimeEntityRecord canonical =
|
|
lifetime.RegisterEntity(Spawn(guid, 1, "vector")).Canonical!;
|
|
bool deleted = false;
|
|
var observer = new CallbackObserver(
|
|
onEntity: delta =>
|
|
{
|
|
if (deleted
|
|
|| delta.Change is not RuntimeEntityChange.Updated)
|
|
{
|
|
return;
|
|
}
|
|
|
|
deleted = lifetime.TryAcceptDelete(
|
|
new DeleteObject.Parsed(guid, 1),
|
|
isLocalPlayer: false,
|
|
removeRetainedObject: false,
|
|
out RuntimeEntityDeleteAcceptance acceptance);
|
|
if (deleted)
|
|
{
|
|
lifetime.CompleteAcceptedDelete(acceptance);
|
|
Assert.Null(lifetime.RetireCanonicalOnly(canonical));
|
|
}
|
|
});
|
|
using IDisposable subscription = lifetime.Events.Subscribe(observer);
|
|
|
|
bool remainedCurrent = lifetime.TryApplyVector(
|
|
new VectorUpdate.Parsed(
|
|
guid,
|
|
new System.Numerics.Vector3(1f, 0f, 0f),
|
|
System.Numerics.Vector3.Zero,
|
|
InstanceSequence: 1,
|
|
VectorSequence: 2),
|
|
acknowledgeProjection: null,
|
|
out _);
|
|
|
|
Assert.True(deleted);
|
|
Assert.False(remainedCurrent);
|
|
Assert.False(lifetime.Entities.TryGetActive(guid, out _));
|
|
}
|
|
|
|
private static RuntimeEntityRecord Accept(
|
|
RuntimeEntityObjectLifetime lifetime,
|
|
WorldSession.EntitySpawn spawn)
|
|
{
|
|
InboundCreateResult accepted = lifetime.Entities.AcceptCreate(spawn);
|
|
return lifetime.Entities.AddActive(accepted.Snapshot);
|
|
}
|
|
|
|
private static WorldSession.EntitySpawn Spawn(
|
|
uint guid,
|
|
ushort instance,
|
|
string name)
|
|
{
|
|
var position = new CreateObject.ServerPosition(
|
|
0x0101FFFFu,
|
|
10f,
|
|
20f,
|
|
5f,
|
|
1f,
|
|
0f,
|
|
0f,
|
|
0f);
|
|
var timestamps = new PhysicsTimestamps(
|
|
Position: 1,
|
|
Movement: 1,
|
|
State: 1,
|
|
Vector: 1,
|
|
Teleport: 0,
|
|
ServerControlledMove: 1,
|
|
ForcePosition: 0,
|
|
ObjDesc: 1,
|
|
Instance: instance);
|
|
var physics = new PhysicsSpawnData(
|
|
RawState: 0x408u,
|
|
Position: position,
|
|
Movement: null,
|
|
AnimationFrame: null,
|
|
SetupTableId: 0x02000001u,
|
|
MotionTableId: 0x09000001u,
|
|
SoundTableId: null,
|
|
PhysicsScriptTableId: null,
|
|
Parent: null,
|
|
Children: null,
|
|
Scale: null,
|
|
Friction: null,
|
|
Elasticity: null,
|
|
Translucency: null,
|
|
Velocity: null,
|
|
Acceleration: null,
|
|
AngularVelocity: null,
|
|
DefaultScriptType: null,
|
|
DefaultScriptIntensity: null,
|
|
Timestamps: timestamps);
|
|
return new WorldSession.EntitySpawn(
|
|
guid,
|
|
position,
|
|
0x02000001u,
|
|
Array.Empty<CreateObject.AnimPartChange>(),
|
|
Array.Empty<CreateObject.TextureChange>(),
|
|
Array.Empty<CreateObject.SubPaletteSwap>(),
|
|
null,
|
|
null,
|
|
name,
|
|
null,
|
|
null,
|
|
0x09000001u,
|
|
PhysicsState: 0x408u,
|
|
InstanceSequence: instance,
|
|
MovementSequence: 1,
|
|
ServerControlSequence: 1,
|
|
PositionSequence: 1,
|
|
Physics: physics);
|
|
}
|
|
|
|
private sealed class RecordingObserver : IRuntimeEntityObjectObserver
|
|
{
|
|
public List<(RuntimeTraceKind Kind, ulong Sequence)> Order { get; } = [];
|
|
public List<RuntimeEventStamp> Stamps { get; } = [];
|
|
public List<RuntimeEntityDelta> Entities { get; } = [];
|
|
public List<RuntimeInventoryDelta> Inventory { get; } = [];
|
|
|
|
public void OnEntity(in RuntimeEntityDelta delta)
|
|
{
|
|
Order.Add((RuntimeTraceKind.Entity, delta.Stamp.Sequence));
|
|
Stamps.Add(delta.Stamp);
|
|
Entities.Add(delta);
|
|
}
|
|
|
|
public void OnInventory(in RuntimeInventoryDelta delta)
|
|
{
|
|
Order.Add((RuntimeTraceKind.Inventory, delta.Stamp.Sequence));
|
|
Stamps.Add(delta.Stamp);
|
|
Inventory.Add(delta);
|
|
}
|
|
}
|
|
|
|
private sealed class CallbackObserver(
|
|
Action<RuntimeEntityDelta>? onEntity = null,
|
|
Action<RuntimeInventoryDelta>? onInventory = null)
|
|
: IRuntimeEntityObjectObserver
|
|
{
|
|
public void OnEntity(in RuntimeEntityDelta delta) =>
|
|
onEntity?.Invoke(delta);
|
|
|
|
public void OnInventory(in RuntimeInventoryDelta delta) =>
|
|
onInventory?.Invoke(delta);
|
|
}
|
|
}
|