refactor(runtime): publish canonical entity object deltas
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>
This commit is contained in:
parent
d3e96ff912
commit
ce3ac310d9
23 changed files with 2352 additions and 666 deletions
|
|
@ -251,7 +251,7 @@ public sealed class LiveEntityInboundAuthorityGateTests
|
|||
Assert.True(timestampAccepted);
|
||||
Assert.True(runtime.TryGetCanonical(Guid, out RuntimeEntityRecord replacement));
|
||||
Assert.Equal((ushort)7, replacement.Incarnation);
|
||||
Assert.Null(replacement.LocalEntityId);
|
||||
Assert.NotNull(replacement.LocalEntityId);
|
||||
Assert.False(runtime.TryGetRecord(Guid, out _));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -469,6 +469,7 @@ public sealed class GameWindowSlice8BoundaryTests
|
|||
"new ResourceShutdownStage(\"frame borrowers\"",
|
||||
"new ResourceShutdownStage(\"session dependents\"",
|
||||
"new ResourceShutdownStage(\"live entities\"",
|
||||
"new ResourceShutdownStage(\"runtime entity/object stream\"",
|
||||
"new ResourceShutdownStage(\"effect dispatch edges\"",
|
||||
"new ResourceShutdownStage(\"live entity dependents\"",
|
||||
"new ResourceShutdownStage(\"submitted GPU work\"",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ using AcDream.Core.Physics;
|
|||
using AcDream.Core.Selection;
|
||||
using AcDream.Core.World;
|
||||
using AcDream.Runtime;
|
||||
using AcDream.Runtime.Entities;
|
||||
using AcDream.Runtime.Session;
|
||||
using AcDream.UI.Abstractions;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
|
|
@ -174,6 +175,172 @@ public sealed class CurrentGameRuntimeAdapterTests
|
|||
Assert.Equal(RuntimeLifecycleState.Stopped, harness.Runtime.Lifecycle.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DirectAndGraphicalHosts_ProduceIdenticalEntityObjectTrace()
|
||||
{
|
||||
WorldSession.EntitySpawn spawn =
|
||||
Spawn(Harness.TargetGuid, instance: 7, cell: 0x12340001u);
|
||||
var direct = new RuntimeEntityObjectLifetime();
|
||||
direct.BindEventContext(static () => default, static () => 0UL);
|
||||
var directTrace = new EntityObjectTrace();
|
||||
using IDisposable directSubscription =
|
||||
direct.Events.Subscribe(directTrace);
|
||||
|
||||
RuntimeEntityRegistrationResult directRegistration =
|
||||
direct.RegisterEntity(spawn);
|
||||
RuntimeEntityRecord directCanonical =
|
||||
Assert.IsType<RuntimeEntityRecord>(directRegistration.Canonical);
|
||||
direct.Objects.AddOrUpdate(Object(spawn));
|
||||
direct.Objects.MoveItem(
|
||||
spawn.Guid,
|
||||
Harness.PlayerGuid,
|
||||
newSlot: 3);
|
||||
var vector = new VectorUpdate.Parsed(
|
||||
spawn.Guid,
|
||||
new Vector3(1f, 2f, 3f),
|
||||
new Vector3(0f, 0f, 0.25f),
|
||||
spawn.InstanceSequence,
|
||||
VectorSequence: 2);
|
||||
Assert.True(direct.TryApplyVector(
|
||||
vector,
|
||||
acknowledgeProjection: null,
|
||||
out _));
|
||||
var state = new SetState.Parsed(
|
||||
spawn.Guid,
|
||||
(uint)(PhysicsStateFlags.ReportCollisions
|
||||
| PhysicsStateFlags.Hidden),
|
||||
spawn.InstanceSequence,
|
||||
StateSequence: 2);
|
||||
Assert.True(direct.TryApplyState(
|
||||
state,
|
||||
acknowledgeProjection: null,
|
||||
out _,
|
||||
out _));
|
||||
WorldSession.EntityPositionUpdate position =
|
||||
Position(spawn.Guid, spawn.InstanceSequence);
|
||||
Assert.True(direct.TryApplyPosition(
|
||||
position,
|
||||
isLocalPlayer: false,
|
||||
forcePositionRotation: null,
|
||||
currentLocalVelocity: null,
|
||||
projectionRequiresTeleportHook: false,
|
||||
acknowledgeProjection: null,
|
||||
out _,
|
||||
out _,
|
||||
out _));
|
||||
Assert.True(direct.TryApplyPickup(
|
||||
new PickupEvent.Parsed(
|
||||
spawn.Guid,
|
||||
spawn.InstanceSequence,
|
||||
PositionSequence: 3),
|
||||
acknowledgeProjection: null,
|
||||
out _));
|
||||
Assert.True(direct.TryAcceptDelete(
|
||||
new DeleteObject.Parsed(spawn.Guid, spawn.InstanceSequence),
|
||||
isLocalPlayer: false,
|
||||
removeRetainedObject: true,
|
||||
out RuntimeEntityDeleteAcceptance directDelete));
|
||||
direct.CompleteAcceptedDelete(directDelete);
|
||||
Assert.Null(direct.RetireCanonicalOnly(directCanonical));
|
||||
|
||||
using var graphical = new Harness();
|
||||
var graphicalTrace = new EntityObjectTrace();
|
||||
using IDisposable graphicalSubscription =
|
||||
graphical.EntityObjects.Events.Subscribe(graphicalTrace);
|
||||
_ = graphical.Entities.RegisterAndMaterializeProjection(spawn);
|
||||
graphical.Objects.AddOrUpdate(Object(spawn));
|
||||
graphical.Objects.MoveItem(
|
||||
spawn.Guid,
|
||||
Harness.PlayerGuid,
|
||||
newSlot: 3);
|
||||
Assert.True(graphical.Entities.TryApplyVector(vector, out _));
|
||||
Assert.True(graphical.Entities.TryApplyState(state, out _, out _));
|
||||
Assert.True(graphical.Entities.TryApplyPosition(
|
||||
position,
|
||||
isLocalPlayer: false,
|
||||
forcePositionRotation: null,
|
||||
currentLocalVelocity: null,
|
||||
out _,
|
||||
out _,
|
||||
out _));
|
||||
Assert.True(graphical.Entities.TryApplyPickup(
|
||||
new PickupEvent.Parsed(
|
||||
spawn.Guid,
|
||||
spawn.InstanceSequence,
|
||||
PositionSequence: 3),
|
||||
out _));
|
||||
Assert.True(graphical.Entities.UnregisterLiveEntity(
|
||||
new DeleteObject.Parsed(spawn.Guid, spawn.InstanceSequence),
|
||||
isLocalPlayer: false,
|
||||
removeRetainedObject: true));
|
||||
|
||||
Assert.Equal(directTrace.Entries, graphicalTrace.Entries);
|
||||
Assert.Equal(0, direct.Entities.Count);
|
||||
Assert.Equal(0, direct.Objects.ObjectCount);
|
||||
Assert.Equal(0, graphical.EntityObjects.Entities.Count);
|
||||
Assert.Equal(0, graphical.Objects.ObjectCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GraphicalObserverFailure_DoesNotStarveLaterObserverOrOwner()
|
||||
{
|
||||
using var harness = new Harness();
|
||||
var throwing = new ThrowingEntityObserver();
|
||||
var recording = new RuntimeTraceRecorder();
|
||||
IDisposable first = harness.Runtime.Subscribe(throwing);
|
||||
IDisposable second = harness.Runtime.Subscribe(recording);
|
||||
|
||||
LiveEntityRecord record =
|
||||
harness.Entities.RegisterAndMaterializeProjection(Spawn(
|
||||
Harness.TargetGuid,
|
||||
instance: 4,
|
||||
cell: 0x12340001u));
|
||||
|
||||
Assert.True(harness.EntityObjects.Entities.IsCurrent(record.Canonical));
|
||||
Assert.Contains(
|
||||
recording.Entries,
|
||||
entry => entry.Kind == RuntimeTraceKind.Entity
|
||||
&& entry.PrimaryObjectId == Harness.TargetGuid);
|
||||
Assert.Equal(1, harness.EntityObjects.Events.SubscriberCount);
|
||||
Assert.Equal(0, harness.EntityObjects.Events.DispatchFailureCount);
|
||||
|
||||
first.Dispose();
|
||||
second.Dispose();
|
||||
Assert.Equal(0, harness.EntityObjects.Events.SubscriberCount);
|
||||
}
|
||||
|
||||
private static ClientObject Object(WorldSession.EntitySpawn spawn) => new()
|
||||
{
|
||||
ObjectId = spawn.Guid,
|
||||
Name = spawn.Name ?? string.Empty,
|
||||
StackSize = 4,
|
||||
Value = 25,
|
||||
ContainerId = Harness.PlayerGuid,
|
||||
ContainerSlot = 2,
|
||||
};
|
||||
|
||||
private static WorldSession.EntityPositionUpdate Position(
|
||||
uint guid,
|
||||
ushort instance) =>
|
||||
new(
|
||||
guid,
|
||||
new CreateObject.ServerPosition(
|
||||
0x12350001u,
|
||||
30f,
|
||||
40f,
|
||||
8f,
|
||||
1f,
|
||||
0f,
|
||||
0f,
|
||||
0f),
|
||||
Velocity: new Vector3(1f, 2f, 3f),
|
||||
PlacementId: null,
|
||||
IsGrounded: true,
|
||||
InstanceSequence: instance,
|
||||
PositionSequence: 2,
|
||||
TeleportSequence: 0,
|
||||
ForcePositionSequence: 0);
|
||||
|
||||
private sealed class Harness : IDisposable
|
||||
{
|
||||
public const uint PlayerGuid = 0x50000002u;
|
||||
|
|
@ -185,10 +352,12 @@ public sealed class CurrentGameRuntimeAdapterTests
|
|||
public Harness()
|
||||
{
|
||||
Identity = new LocalPlayerIdentityState();
|
||||
Entities = LiveEntityRuntimeFixture.Create(
|
||||
EntityObjects = new RuntimeEntityObjectLifetime();
|
||||
Entities = new LiveEntityRuntime(
|
||||
new GpuWorldState(),
|
||||
new NoopEntityResources());
|
||||
Objects = new ClientObjectTable();
|
||||
new NoopEntityResources(),
|
||||
EntityObjects);
|
||||
Objects = EntityObjects.Objects;
|
||||
Chat = new ChatLog();
|
||||
Selection = new SelectionState();
|
||||
MovementInput = new DispatcherMovementInputSource();
|
||||
|
|
@ -233,7 +402,7 @@ public sealed class CurrentGameRuntimeAdapterTests
|
|||
Commands,
|
||||
Identity,
|
||||
Entities,
|
||||
Objects,
|
||||
EntityObjects,
|
||||
Chat,
|
||||
new LocalPlayerControllerSlot(),
|
||||
WorldReveal,
|
||||
|
|
@ -246,6 +415,7 @@ public sealed class CurrentGameRuntimeAdapterTests
|
|||
|
||||
public RuntimeOptions Options { get; }
|
||||
public LocalPlayerIdentityState Identity { get; }
|
||||
public RuntimeEntityObjectLifetime EntityObjects { get; }
|
||||
public LiveEntityRuntime Entities { get; }
|
||||
public ClientObjectTable Objects { get; }
|
||||
public ChatLog Chat { get; }
|
||||
|
|
@ -541,6 +711,64 @@ public sealed class CurrentGameRuntimeAdapterTests
|
|||
ActivationType activation) => false;
|
||||
}
|
||||
|
||||
private sealed class EntityObjectTrace : IRuntimeEntityObjectObserver
|
||||
{
|
||||
public List<string> Entries { get; } = [];
|
||||
|
||||
public void OnEntity(in RuntimeEntityDelta delta) =>
|
||||
Entries.Add(
|
||||
$"E:{delta.Stamp.Sequence}:{delta.Change}:"
|
||||
+ $"{delta.Entity.Identity.ServerGuid:X8}:"
|
||||
+ $"{delta.Entity.Identity.LocalEntityId}:"
|
||||
+ $"{delta.Entity.Identity.Incarnation}:"
|
||||
+ $"{delta.Entity.CellId:X8}:"
|
||||
+ $"{delta.Entity.PhysicsState:X8}:"
|
||||
+ $"{delta.Entity.Position?.ObjCellId:X8}:"
|
||||
+ $"{delta.Entity.Position?.Frame.Origin.X}:"
|
||||
+ $"{delta.Entity.Position?.Frame.Origin.Y}:"
|
||||
+ $"{delta.Entity.Position?.Frame.Origin.Z}");
|
||||
|
||||
public void OnInventory(in RuntimeInventoryDelta delta) =>
|
||||
Entries.Add(
|
||||
$"I:{delta.Stamp.Sequence}:{delta.Change}:"
|
||||
+ $"{delta.Item.ObjectId:X8}:"
|
||||
+ $"{delta.Item.Incarnation}:"
|
||||
+ $"{delta.Item.ContainerId:X8}:"
|
||||
+ $"{delta.Item.ContainerSlot}:"
|
||||
+ $"{delta.Item.StackSize}:"
|
||||
+ $"{delta.Item.Value}");
|
||||
}
|
||||
|
||||
private sealed class ThrowingEntityObserver : IRuntimeEventObserver
|
||||
{
|
||||
public void OnLifecycle(in RuntimeLifecycleDelta delta)
|
||||
{
|
||||
}
|
||||
|
||||
public void OnCommand(in RuntimeCommandDelta delta)
|
||||
{
|
||||
}
|
||||
|
||||
public void OnEntity(in RuntimeEntityDelta delta) =>
|
||||
throw new InvalidOperationException("observer");
|
||||
|
||||
public void OnInventory(in RuntimeInventoryDelta delta)
|
||||
{
|
||||
}
|
||||
|
||||
public void OnChat(in RuntimeChatDelta delta)
|
||||
{
|
||||
}
|
||||
|
||||
public void OnMovement(in RuntimeMovementDelta delta)
|
||||
{
|
||||
}
|
||||
|
||||
public void OnPortal(in RuntimePortalDelta delta)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SelectionQuery(uint target) : IWorldSelectionQuery
|
||||
{
|
||||
public uint? PickAtCursor(bool includeSelf) => target;
|
||||
|
|
|
|||
|
|
@ -1168,7 +1168,8 @@ public sealed class LandblockPresentationPipelineTests
|
|||
nearRadius: 0,
|
||||
farRadius: 0,
|
||||
onLandblockLoaded: _ => recoveryCalls++,
|
||||
ensureEnvCellMeshes: _ => replayCalls++);
|
||||
ensureEnvCellMeshes: _ => replayCalls++,
|
||||
workBudgetOptions: GenerousWorkBudget());
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => controller.Tick(0x80, 0x80));
|
||||
Assert.True(state.IsNearTier(landblockId));
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ public sealed class LiveEntityHydrationControllerTests
|
|||
|
||||
RuntimeEntityRecord canonical = fixture.Canonical;
|
||||
Assert.False(fixture.Runtime.TryGetRecord(Guid, out _));
|
||||
Assert.Null(canonical.LocalEntityId);
|
||||
Assert.NotNull(canonical.LocalEntityId);
|
||||
Assert.Equal(0, fixture.Resources.RegisterCount);
|
||||
Assert.Empty(fixture.Materializer.Calls);
|
||||
|
||||
|
|
@ -134,7 +134,7 @@ public sealed class LiveEntityHydrationControllerTests
|
|||
fixture.Controller.OnCreate(spawn);
|
||||
RuntimeEntityRecord canonical = fixture.Canonical;
|
||||
Assert.False(fixture.Runtime.TryGetRecord(Guid, out _));
|
||||
Assert.Null(canonical.LocalEntityId);
|
||||
Assert.NotNull(canonical.LocalEntityId);
|
||||
|
||||
Assert.True(fixture.Controller.OnAppearance(ObjDesc(2, 0x04000022u)));
|
||||
|
||||
|
|
@ -522,7 +522,7 @@ public sealed class LiveEntityHydrationControllerTests
|
|||
|
||||
RuntimeEntityRecord canonical = fixture.Canonical;
|
||||
Assert.False(fixture.Runtime.TryGetRecord(Guid, out _));
|
||||
Assert.Null(canonical.LocalEntityId);
|
||||
Assert.NotNull(canonical.LocalEntityId);
|
||||
Assert.Equal(1, resources.RegisterCount);
|
||||
Assert.Equal(1, resources.UnregisterCount);
|
||||
|
||||
|
|
@ -852,7 +852,7 @@ public sealed class LiveEntityHydrationControllerTests
|
|||
RuntimeEntityRecord replacement = fixture.Canonical;
|
||||
Assert.Equal((ushort)2, replacement.Generation);
|
||||
Assert.Equal("replacement", replacement.Snapshot.Name);
|
||||
Assert.Null(replacement.LocalEntityId);
|
||||
Assert.NotNull(replacement.LocalEntityId);
|
||||
Assert.False(fixture.Runtime.TryGetRecord(Guid, out _));
|
||||
Assert.Equal(0, fixture.Runtime.PendingTeardownCount);
|
||||
}
|
||||
|
|
@ -1368,7 +1368,7 @@ public sealed class LiveEntityHydrationControllerTests
|
|||
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
|
||||
|
||||
RuntimeEntityRecord canonical = fixture.Canonical;
|
||||
Assert.Null(canonical.LocalEntityId);
|
||||
Assert.NotNull(canonical.LocalEntityId);
|
||||
Assert.False(fixture.Runtime.TryGetRecord(Guid, out _));
|
||||
Assert.Equal(0, fixture.Resources.RegisterCount);
|
||||
|
||||
|
|
|
|||
|
|
@ -312,7 +312,7 @@ public sealed class LiveEntityPhysicsHostOwnershipTests
|
|||
|
||||
Assert.True(runtime.TryGetCanonical(guid, out var replacement));
|
||||
Assert.Equal((ushort)2, replacement.Incarnation);
|
||||
Assert.Null(replacement.LocalEntityId);
|
||||
Assert.NotNull(replacement.LocalEntityId);
|
||||
Assert.Null(replacement.PhysicsBody);
|
||||
Assert.False(runtime.TryGetRecord(guid, out _));
|
||||
Assert.Null(retired.RemoteMotionRuntime);
|
||||
|
|
|
|||
|
|
@ -1656,7 +1656,7 @@ public sealed class LiveEntityRuntimeTests
|
|||
|
||||
Assert.False(runtime.TryGetRecord(spawn.Guid, out _));
|
||||
RuntimeEntityRecord canonical = CurrentCanonical(runtime, spawn.Guid);
|
||||
Assert.Null(canonical.LocalEntityId);
|
||||
Assert.NotNull(canonical.LocalEntityId);
|
||||
Assert.Equal(0, runtime.MaterializedCount);
|
||||
Assert.Empty(runtime.VisibleRecords);
|
||||
Assert.Empty(spatial.Entities);
|
||||
|
|
@ -1901,7 +1901,7 @@ public sealed class LiveEntityRuntimeTests
|
|||
Assert.NotNull(replacement.PriorGenerationCleanupFailure);
|
||||
RuntimeEntityRecord canonical = CurrentCanonical(runtime, guid);
|
||||
Assert.Equal((ushort)2, canonical.Generation);
|
||||
Assert.Null(canonical.LocalEntityId);
|
||||
Assert.NotNull(canonical.LocalEntityId);
|
||||
Assert.False(runtime.TryGetRecord(guid, out _));
|
||||
WorldEntity installed = runtime.MaterializeLiveEntity(
|
||||
guid,
|
||||
|
|
@ -2459,7 +2459,7 @@ public sealed class LiveEntityRuntimeTests
|
|||
&& exception.Message.Contains("active logical-lifetime transition", StringComparison.Ordinal));
|
||||
RuntimeEntityRecord current = CurrentCanonical(runtime, guid);
|
||||
Assert.Equal((ushort)2, current.Generation);
|
||||
Assert.Null(current.LocalEntityId);
|
||||
Assert.NotNull(current.LocalEntityId);
|
||||
Assert.False(runtime.TryGetRecord(guid, out _));
|
||||
Assert.Empty(spatial.Entities);
|
||||
Assert.DoesNotContain(
|
||||
|
|
@ -2493,7 +2493,7 @@ public sealed class LiveEntityRuntimeTests
|
|||
Assert.False(outer.LogicalRegistrationCreated);
|
||||
RuntimeEntityRecord current = CurrentCanonical(runtime, guid);
|
||||
Assert.Equal((ushort)3, current.Generation);
|
||||
Assert.Null(current.LocalEntityId);
|
||||
Assert.NotNull(current.LocalEntityId);
|
||||
Assert.False(runtime.TryGetRecord(guid, out _));
|
||||
Assert.Empty(spatial.Entities);
|
||||
Assert.Equal(1, resources.RegisterCount);
|
||||
|
|
@ -2524,8 +2524,8 @@ public sealed class LiveEntityRuntimeTests
|
|||
Assert.True(outer.LogicalRegistrationCreated);
|
||||
RuntimeEntityRecord replacement = CurrentCanonical(runtime, guid);
|
||||
Assert.Equal((ushort)2, replacement.Generation);
|
||||
Assert.Null(replacement.LocalEntityId);
|
||||
Assert.Null(CurrentCanonical(runtime, unrelatedGuid).LocalEntityId);
|
||||
Assert.NotNull(replacement.LocalEntityId);
|
||||
Assert.NotNull(CurrentCanonical(runtime, unrelatedGuid).LocalEntityId);
|
||||
Assert.False(runtime.TryGetRecord(guid, out _));
|
||||
Assert.False(runtime.TryGetRecord(unrelatedGuid, out _));
|
||||
Assert.Equal(2, runtime.Count);
|
||||
|
|
@ -2587,7 +2587,7 @@ public sealed class LiveEntityRuntimeTests
|
|||
Assert.False(outer.LogicalRegistrationCreated);
|
||||
RuntimeEntityRecord current = CurrentCanonical(runtime, guid);
|
||||
Assert.Equal((ushort)3, current.Generation);
|
||||
Assert.Null(current.LocalEntityId);
|
||||
Assert.NotNull(current.LocalEntityId);
|
||||
Assert.False(runtime.TryGetRecord(guid, out _));
|
||||
Assert.Equal(1, resources.RegisterCount);
|
||||
Assert.Equal(1, resources.UnregisterCount);
|
||||
|
|
@ -2619,7 +2619,7 @@ public sealed class LiveEntityRuntimeTests
|
|||
&& exception.Message == "old incarnation cleanup failed");
|
||||
RuntimeEntityRecord current = CurrentCanonical(runtime, guid);
|
||||
Assert.Equal((ushort)3, current.Generation);
|
||||
Assert.Null(current.LocalEntityId);
|
||||
Assert.NotNull(current.LocalEntityId);
|
||||
Assert.False(runtime.TryGetRecord(guid, out _));
|
||||
Assert.Equal(1, resources.RegisterCount);
|
||||
Assert.Equal(1, resources.UnregisterCount);
|
||||
|
|
@ -2671,7 +2671,7 @@ public sealed class LiveEntityRuntimeTests
|
|||
Assert.Contains("atomic resource registration", error.Message, StringComparison.Ordinal);
|
||||
RuntimeEntityRecord current = CurrentCanonical(runtime, guid);
|
||||
Assert.Equal((ushort)1, current.Generation);
|
||||
Assert.Null(current.LocalEntityId);
|
||||
Assert.NotNull(current.LocalEntityId);
|
||||
Assert.False(runtime.TryGetRecord(guid, out _));
|
||||
Assert.Equal(1, resources.RegisterCount);
|
||||
Assert.Equal(1, resources.UnregisterCount);
|
||||
|
|
@ -2695,7 +2695,7 @@ public sealed class LiveEntityRuntimeTests
|
|||
|
||||
Assert.Contains("atomic resource registration", error.Message, StringComparison.Ordinal);
|
||||
RuntimeEntityRecord current = CurrentCanonical(runtime, guid);
|
||||
Assert.Null(current.LocalEntityId);
|
||||
Assert.NotNull(current.LocalEntityId);
|
||||
Assert.False(runtime.TryGetRecord(guid, out _));
|
||||
Assert.Equal(1, resources.RegisterCount);
|
||||
Assert.Equal(1, resources.UnregisterCount);
|
||||
|
|
|
|||
|
|
@ -4,8 +4,11 @@ using AcDream.App.Rendering;
|
|||
using AcDream.App.Rendering.Scene;
|
||||
using AcDream.App.Rendering.Vfx;
|
||||
using AcDream.App.Rendering.Wb;
|
||||
using AcDream.App.Runtime;
|
||||
using AcDream.App.Streaming;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Runtime;
|
||||
using AcDream.Runtime.Entities;
|
||||
|
||||
namespace AcDream.App.Tests.World;
|
||||
|
|
@ -230,6 +233,46 @@ public sealed class RuntimeEntityOwnershipTests
|
|||
property => IsPresentationType(property.PropertyType));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CurrentRuntimeAdapters_DoNotRetainEntityOrInventoryMirrors()
|
||||
{
|
||||
FieldInfo[] viewFields = typeof(CurrentGameRuntimeViewAdapter)
|
||||
.GetFields(BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
Assert.Contains(
|
||||
viewFields,
|
||||
field => field.FieldType == typeof(IRuntimeEntityView));
|
||||
Assert.Contains(
|
||||
viewFields,
|
||||
field => field.FieldType == typeof(IRuntimeInventoryView));
|
||||
Assert.DoesNotContain(
|
||||
typeof(CurrentGameRuntimeViewAdapter).GetNestedTypes(
|
||||
BindingFlags.NonPublic),
|
||||
type => type.Name is "EntityView" or "InventoryView");
|
||||
|
||||
FieldInfo[] eventFields = typeof(CurrentGameRuntimeEventAdapter)
|
||||
.GetFields(BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
Assert.DoesNotContain(
|
||||
eventFields,
|
||||
field => field.FieldType == typeof(LiveEntityRuntime)
|
||||
|| field.FieldType == typeof(ClientObjectTable)
|
||||
|| field.FieldType == typeof(RuntimeEventSequencer));
|
||||
Assert.Contains(
|
||||
eventFields,
|
||||
field => field.FieldType
|
||||
== typeof(RuntimeEntityObjectLifetime));
|
||||
|
||||
string root = FindRepositoryRoot();
|
||||
string liveSource = File.ReadAllText(Path.Combine(
|
||||
root,
|
||||
"src",
|
||||
"AcDream.App",
|
||||
"World",
|
||||
"LiveEntityRuntime.cs"));
|
||||
Assert.DoesNotContain("_entityObjects.PublishEntity", liveSource);
|
||||
Assert.DoesNotContain("_directory.TryApply", liveSource);
|
||||
Assert.DoesNotContain("_directory.RemoveActive", liveSource);
|
||||
}
|
||||
|
||||
private static bool IsPresentationType(Type type)
|
||||
{
|
||||
string? ns = type.Namespace;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
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;
|
||||
|
|
@ -120,14 +121,19 @@ public sealed class RuntimeEntityObjectLifetimeTests
|
|||
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));
|
||||
}
|
||||
|
||||
|
|
@ -146,6 +152,7 @@ public sealed class RuntimeEntityObjectLifetimeTests
|
|||
Assert.True(owner.TryAcceptDelete(
|
||||
new DeleteObject.Parsed(spawn.Guid, spawn.InstanceSequence),
|
||||
isLocalPlayer: false,
|
||||
removeRetainedObject: true,
|
||||
out RuntimeEntityDeleteAcceptance acceptance));
|
||||
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
|
|
@ -175,6 +182,509 @@ public sealed class RuntimeEntityObjectLifetimeTests
|
|||
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)
|
||||
|
|
@ -248,4 +758,38 @@ public sealed class RuntimeEntityObjectLifetimeTests
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -80,6 +80,21 @@ public sealed class GameRuntimeContractTests
|
|||
Assert.Equal(99UL, independent.FrameNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EventSequencer_RestartsAtOneForEachSessionGeneration()
|
||||
{
|
||||
var sequencer = new RuntimeEventSequencer();
|
||||
|
||||
RuntimeEventStamp first = sequencer.Next(new(4), 10);
|
||||
RuntimeEventStamp second = sequencer.Next(new(4), 11);
|
||||
RuntimeEventStamp replacement = sequencer.Next(new(5), 12);
|
||||
|
||||
Assert.Equal(1UL, first.Sequence);
|
||||
Assert.Equal(2UL, second.Sequence);
|
||||
Assert.Equal(1UL, replacement.Sequence);
|
||||
Assert.Equal(new RuntimeGenerationToken(5), replacement.Generation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TraceRecorderNormalizesTypedDeltasWithoutRetainingOwners()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue