From 74c9b155bdd434e01d2e072fe2966ecff49913bc Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 1 Aug 2026 15:00:49 +0200 Subject: [PATCH] feat(app): project canonical runtime placements --- src/AcDream.App/World/LiveEntityRuntime.cs | 308 +++++++ .../World/RuntimePlacementPresentationSink.cs | 175 ++++ .../World/RuntimeWorldTransitState.cs | 27 + .../RuntimePlacementPresentationSinkTests.cs | 813 ++++++++++++++++++ .../World/RuntimeWorldTransitStateTests.cs | 72 ++ 5 files changed, 1395 insertions(+) create mode 100644 src/AcDream.App/World/RuntimePlacementPresentationSink.cs create mode 100644 tests/AcDream.App.Tests/World/RuntimePlacementPresentationSinkTests.cs diff --git a/src/AcDream.App/World/LiveEntityRuntime.cs b/src/AcDream.App/World/LiveEntityRuntime.cs index 5bac67ef..36650ef7 100644 --- a/src/AcDream.App/World/LiveEntityRuntime.cs +++ b/src/AcDream.App/World/LiveEntityRuntime.cs @@ -5,6 +5,7 @@ using AcDream.Core.Physics; using AcDream.Core.World; using AcDream.Runtime; using AcDream.Runtime.Entities; +using AcDream.Runtime.Physics; using System.Numerics; using System.Runtime.ExceptionServices; @@ -386,6 +387,15 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource private readonly Dictionary _spatialAnimations = new(); private readonly List _spatialRootCanonicalScratch = new(); private readonly List _spatialRemoteCanonicalScratch = new(); + // Runtime placement receipts are presentation observations of an already + // committed canonical SetPosition transaction. GpuWorldState still emits + // its ordinary visibility callback while that observation rebuckets or + // withdraws a sidecar, so pin the exact incarnation while the callback is + // in flight. A depth (rather than a bool) preserves nested/re-entrant + // projection mutations without ever turning graphical visibility into a + // Runtime physics/workset mutation. + private readonly Dictionary + _presentationOnlySpatialMutationDepth = new(); private bool _isClearing; private bool _sessionClearPendingFinalization; private bool _isRegisteringResources; @@ -446,6 +456,7 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource _projections.VisibleRecords; internal IReadOnlyCollection CanonicalRecords => _directory.ActiveRecords; + internal ulong SessionLifetimeVersion => _directory.SessionLifetimeVersion; internal RuntimePhysicsState Physics => _physics; public IReadOnlyDictionary Snapshots => _directory.Snapshots; internal int AnimationRuntimeCount @@ -863,6 +874,269 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource return true; } + /// + /// Applies one canonical Runtime placement receipt to the graphical + /// sidecar only. Runtime has already committed identity, position, + /// collision residence, object-clock state, and simulation worksets. + /// + internal bool TryApplyRuntimePlacementProjection( + in RuntimePlacementProjectionSnapshot projection) + { + if (projection.Kind is RuntimePlacementProjectionKind.Discard) + { + // Discard cancels only an unacknowledged observation. If its Place + // was already projected, retail keeps that last committed frame + // visible until a later canonical Place or Withdraw supersedes it. + return true; + } + + RuntimePlacementProjectionToken token = projection.Token; + if (!TryGetRuntimePlacementProjectionRecord( + token, + requirePlacementVersions: + projection.Kind is RuntimePlacementProjectionKind.Place, + out LiveEntityRecord? record) + || record.WorldEntity is not { } entity) + { + return false; + } + if (projection.Kind is RuntimePlacementProjectionKind.Place + && !_spatial.IsLoaded( + (token.ExactCellId & 0xFFFF0000u) | 0xFFFFu)) + { + // A canonical SetPosition receipt is not permission to create a + // pending graphical bucket. Keep the FIFO head unacknowledged + // until the destination backend exists, otherwise GpuWorldState's + // later pending-drain edge escapes this exact receipt transaction. + return false; + } + + return projection.Kind switch + { + RuntimePlacementProjectionKind.Place => + TryApplyRuntimePlacementPlace(in projection, record, entity), + RuntimePlacementProjectionKind.Withdraw => + TryApplyRuntimePlacementWithdrawal(token, record, entity), + _ => false, + }; + } + + private bool TryApplyRuntimePlacementPlace( + in RuntimePlacementProjectionSnapshot projection, + LiveEntityRecord record, + WorldEntity entity) + { + RuntimePlacementProjectionToken token = projection.Token; + RuntimeEntityKey key = token.Entity; + ulong projectionOperation = ++record.ProjectionMutationVersion; + + entity.SetPosition(projection.WorldPosition); + entity.Rotation = projection.Orientation; + entity.ParentCellId = token.ExactCellId; + entity.EffectCellId = token.ExactCellId; + record.IsSpatiallyProjected = true; + + Exception? spatialNotificationFailure = null; + uint priorRebucketingGuid = _rebucketingGuid; + _rebucketingGuid = record.ServerGuid; + BeginPresentationOnlySpatialMutation(key); + try + { + try + { + _spatial.RebucketLiveEntity(key, entity, token.ExactCellId); + } + catch (AggregateException error) + { + spatialNotificationFailure = error; + } + } + finally + { + EndPresentationOnlySpatialMutation(key); + _rebucketingGuid = priorRebucketingGuid; + } + + if (!IsCurrentProjectionOperation( + record.ServerGuid, + record, + projectionOperation) + || !TryGetRuntimePlacementProjectionRecord( + token, + requirePlacementVersions: true, + out LiveEntityRecord? current) + || !ReferenceEquals(current, record) + || !ReferenceEquals(current.WorldEntity, entity)) + { + ThrowAfterCommittedProjectionChange( + record.ServerGuid, + spatialNotificationFailure, + runtimeNotificationFailure: null); + return false; + } + + bool visible = _spatial.IsLiveEntityProjectionResident(key); + record.IsSpatiallyVisible = visible; + RefreshSpatialPresentationIndexes(record); + RefreshPresentation(record); + + if (!IsCurrentProjectionOperation( + record.ServerGuid, + record, + projectionOperation)) + { + ThrowAfterCommittedProjectionChange( + record.ServerGuid, + spatialNotificationFailure, + runtimeNotificationFailure: null); + return false; + } + + ThrowAfterCommittedProjectionChange( + record.ServerGuid, + spatialNotificationFailure, + runtimeNotificationFailure: null); + return true; + } + + private bool TryApplyRuntimePlacementWithdrawal( + in RuntimePlacementProjectionToken token, + LiveEntityRecord record, + WorldEntity entity) + { + RuntimeEntityKey key = token.Entity; + ulong projectionOperation = ++record.ProjectionMutationVersion; + record.IsSpatiallyProjected = false; + + Exception? spatialNotificationFailure = null; + uint priorRebucketingGuid = _rebucketingGuid; + _rebucketingGuid = record.ServerGuid; + BeginPresentationOnlySpatialMutation(key); + try + { + try + { + _spatial.RemoveLiveEntityProjection(entity); + } + catch (AggregateException error) + { + spatialNotificationFailure = error; + } + } + finally + { + EndPresentationOnlySpatialMutation(key); + _rebucketingGuid = priorRebucketingGuid; + } + + if (!IsCurrentProjectionOperation( + record.ServerGuid, + record, + projectionOperation) + || !TryGetRuntimePlacementProjectionRecord( + token, + requirePlacementVersions: false, + out LiveEntityRecord? current) + || !ReferenceEquals(current, record) + || !ReferenceEquals(current.WorldEntity, entity)) + { + ThrowAfterCommittedProjectionChange( + record.ServerGuid, + spatialNotificationFailure, + runtimeNotificationFailure: null); + return false; + } + + record.IsSpatiallyVisible = false; + RefreshSpatialPresentationIndexes(record); + RefreshPresentation(record); + + if (!IsCurrentProjectionOperation( + record.ServerGuid, + record, + projectionOperation)) + { + ThrowAfterCommittedProjectionChange( + record.ServerGuid, + spatialNotificationFailure, + runtimeNotificationFailure: null); + return false; + } + + ThrowAfterCommittedProjectionChange( + record.ServerGuid, + spatialNotificationFailure, + runtimeNotificationFailure: null); + return true; + } + + private bool TryGetRuntimePlacementProjectionRecord( + in RuntimePlacementProjectionToken token, + bool requirePlacementVersions, + out LiveEntityRecord record) + { + if (!token.IsValid + || token.SessionLifetimeVersion != _directory.SessionLifetimeVersion + || !_projections.TryGet(token.Entity, out record!) + || !_directory.IsCurrent(record.Canonical) + || record.Canonical.Key != token.Entity + || RequireProjectionKey(record) != token.Entity + || !IsValidPortalPlacementAuthority(token)) + { + record = null!; + return false; + } + + if (requirePlacementVersions + && (record.Canonical.PositionAuthorityVersion + != token.PositionAuthorityVersion + || record.Canonical.SpatialAuthorityVersion + != token.SpatialAuthorityVersion + || record.Canonical.PlacementCommitVersion + != token.PlacementCommitVersion + || record.Canonical.FullCellId != token.ExactCellId)) + { + record = null!; + return false; + } + + return true; + } + + private static bool IsValidPortalPlacementAuthority( + in RuntimePlacementProjectionToken token) + { + RuntimePortalPlacementAuthority portal = token.Portal; + if (!portal.Present) + return portal.IsEmpty; + + return portal.IsValid + && portal.Projection.DestinationCell == token.ExactCellId; + } + + private void BeginPresentationOnlySpatialMutation(RuntimeEntityKey key) + { + _presentationOnlySpatialMutationDepth.TryGetValue(key, out int depth); + _presentationOnlySpatialMutationDepth[key] = checked(depth + 1); + } + + private void EndPresentationOnlySpatialMutation(RuntimeEntityKey key) + { + if (!_presentationOnlySpatialMutationDepth.TryGetValue( + key, + out int depth) + || depth <= 0) + { + throw new InvalidOperationException( + "Presentation-only spatial mutation depth was not balanced."); + } + + if (depth == 1) + _presentationOnlySpatialMutationDepth.Remove(key); + else + _presentationOnlySpatialMutationDepth[key] = depth - 1; + } + /// /// Removes only the render-bucket reference. The logical record and every /// create-time resource remain alive for later projection/rebucketing. @@ -2588,6 +2862,33 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource _physics.AcknowledgeSpatialProjection(record.Canonical, spatial); + RefreshSpatialPresentationIndexes(record, current, spatial, key); + } + + private void RefreshSpatialPresentationIndexes(LiveEntityRecord record) + { + bool current = IsCurrentRecord(record); + bool spatial = current && HasSpatialRuntimeProjection(record); + if (record.ProjectionKey is not { } key) + { + if (record.WorldEntity is not null) + { + throw new InvalidOperationException( + $"Materialized live entity 0x{record.ServerGuid:X8}/{record.Generation} has no exact projection key."); + } + return; + } + + RefreshSpatialPresentationIndexes(record, current, spatial, key); + } + + private void RefreshSpatialPresentationIndexes( + LiveEntityRecord record, + bool current, + bool spatial, + RuntimeEntityKey key) + { + if (record.WorldEntity is not null) { if (spatial && record.AnimationRuntime is { } animation) @@ -2641,6 +2942,13 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource bool wasVisible = record.IsSpatiallyVisible; if (RequireProjectionKey(record) != key) return; + if (_presentationOnlySpatialMutationDepth.ContainsKey(key)) + { + record.IsSpatiallyVisible = visible; + RefreshSpatialPresentationIndexes(record); + RefreshPresentation(record); + return; + } bool wasOrdinaryRoot = _physics.IsSpatialRoot(record.Canonical); record.IsSpatiallyVisible = visible; bool isOrdinaryRoot = record.ProjectionKind is LiveEntityProjectionKind.World diff --git a/src/AcDream.App/World/RuntimePlacementPresentationSink.cs b/src/AcDream.App/World/RuntimePlacementPresentationSink.cs new file mode 100644 index 00000000..1bef262f --- /dev/null +++ b/src/AcDream.App/World/RuntimePlacementPresentationSink.cs @@ -0,0 +1,175 @@ +using AcDream.Runtime.Physics; +using AcDream.Runtime.World; +using AcDream.App.Physics; +using AcDream.App.Rendering.Vfx; +using AcDream.Core.Plugins; +using AcDream.Core.World; +using AcDream.Plugin.Abstractions; + +namespace AcDream.App.World; + +/// +/// Graphical projection sink for canonical Runtime SetPosition receipts. It +/// owns only App-facing world, effect-pose, cached-local-shadow, selection, +/// and renderer/VFX visibility projections. Runtime physics, shadows, body +/// state, clocks, and worksets were committed before this sink is invoked. +/// +/// This adapter deliberately owns no subscription: production composition +/// activates the shared observer only after both graphical and no-window hosts +/// implement the same presentation-only contract. +/// +internal sealed class RuntimePlacementPresentationSink + : IRuntimePlacementProjectionSink +{ + private readonly LiveEntityRuntime _liveEntities; + private readonly RuntimeWorldTransitState _transit; + private readonly WorldGameState _worldState; + private readonly WorldEvents _worldEvents; + private readonly EntityEffectPoseRegistry _effectPoses; + private readonly LocalPlayerShadowState _localPlayerShadow; + private readonly Func _localPlayerGuid; + private readonly Action _clearSelectionForUnavailableEntity; + private readonly Action[] _visibilitySinks; + + public RuntimePlacementPresentationSink( + LiveEntityRuntime liveEntities, + RuntimeWorldTransitState transit, + WorldGameState worldState, + WorldEvents worldEvents, + EntityEffectPoseRegistry effectPoses, + LocalPlayerShadowState localPlayerShadow, + Func localPlayerGuid, + Action clearSelectionForUnavailableEntity, + IEnumerable>? visibilitySinks = null) + { + _liveEntities = liveEntities + ?? throw new ArgumentNullException(nameof(liveEntities)); + _transit = transit ?? throw new ArgumentNullException(nameof(transit)); + _worldState = worldState ?? throw new ArgumentNullException(nameof(worldState)); + _worldEvents = worldEvents ?? throw new ArgumentNullException(nameof(worldEvents)); + _effectPoses = effectPoses + ?? throw new ArgumentNullException(nameof(effectPoses)); + _localPlayerShadow = localPlayerShadow + ?? throw new ArgumentNullException(nameof(localPlayerShadow)); + _localPlayerGuid = localPlayerGuid + ?? throw new ArgumentNullException(nameof(localPlayerGuid)); + _clearSelectionForUnavailableEntity = clearSelectionForUnavailableEntity + ?? throw new ArgumentNullException( + nameof(clearSelectionForUnavailableEntity)); + _visibilitySinks = visibilitySinks?.ToArray() + ?? Array.Empty>(); + if (_visibilitySinks.Any(static sink => sink is null)) + throw new ArgumentException( + "Presentation visibility sinks cannot contain null.", + nameof(visibilitySinks)); + } + + public bool TryApply(in RuntimePlacementProjectionSnapshot projection) + { + if (projection.Kind is RuntimePlacementProjectionKind.Place + && !_transit.IsCurrentPlacementAuthority( + projection.Token.Portal, + projection.Token.ExactCellId)) + { + return false; + } + + if (!_liveEntities.TryApplyRuntimePlacementProjection(in projection)) + return false; + if (projection.Kind is RuntimePlacementProjectionKind.Discard) + return true; + if (!_liveEntities.TryGetRecord( + projection.Token.Entity, + out LiveEntityRecord record) + || record.WorldEntity is not { } entity) + { + return false; + } + + return projection.Kind switch + { + RuntimePlacementProjectionKind.Place => + TryPublishPlace(record, entity), + RuntimePlacementProjectionKind.Withdraw => + TryPublishWithdrawal(record, entity), + _ => false, + }; + } + + private bool TryPublishPlace(LiveEntityRecord record, WorldEntity entity) + { + if (!IsCurrent(record, entity)) + return false; + + WorldEntitySnapshot snapshot = Snapshot(entity); + _worldState.Add(snapshot); + if (!IsCurrent(record, entity)) + return false; + _worldEvents.UpsertCurrent(snapshot); + if (!IsCurrent(record, entity)) + return false; + _effectPoses.PublishMeshRefs(entity); + if (!IsCurrent(record, entity)) + return false; + + if (record.ServerGuid == _localPlayerGuid()) + { + _localPlayerShadow.Set( + entity.Position, + entity.Rotation, + record.FullCellId); + } + + for (int i = 0; i < _visibilitySinks.Length; i++) + { + _visibilitySinks[i](record, true); + if (!IsCurrent(record, entity)) + return false; + } + return true; + } + + private bool TryPublishWithdrawal( + LiveEntityRecord record, + WorldEntity entity) + { + if (!IsCurrent(record, entity)) + return false; + + for (int i = 0; i < _visibilitySinks.Length; i++) + { + _visibilitySinks[i](record, false); + if (!IsCurrent(record, entity)) + return false; + } + + _worldState.RemoveById(entity.Id); + if (!IsCurrent(record, entity)) + return false; + _worldEvents.ForgetEntity(entity.Id); + if (!IsCurrent(record, entity)) + return false; + _effectPoses.Remove(entity.Id); + if (!IsCurrent(record, entity)) + return false; + if (record.ServerGuid == _localPlayerGuid()) + _localPlayerShadow.Clear(); + if (!IsCurrent(record, entity)) + return false; + _clearSelectionForUnavailableEntity(record.ServerGuid); + return IsCurrent(record, entity); + } + + private bool IsCurrent(LiveEntityRecord record, WorldEntity entity) => + _liveEntities.TryGetRecord( + record.ProjectionKey!.Value, + out LiveEntityRecord current) + && ReferenceEquals(current, record) + && ReferenceEquals(current.WorldEntity, entity); + + private static WorldEntitySnapshot Snapshot(WorldEntity entity) => new( + entity.Id, + entity.SourceGfxObjOrSetupId, + entity.Position, + entity.Rotation); +} diff --git a/src/AcDream.Runtime/World/RuntimeWorldTransitState.cs b/src/AcDream.Runtime/World/RuntimeWorldTransitState.cs index ebcad65d..9a6a889c 100644 --- a/src/AcDream.Runtime/World/RuntimeWorldTransitState.cs +++ b/src/AcDream.Runtime/World/RuntimeWorldTransitState.cs @@ -1,4 +1,5 @@ using System.Globalization; +using AcDream.Runtime.Physics; namespace AcDream.Runtime.World; @@ -247,6 +248,32 @@ public sealed class RuntimeWorldTransitState return false; } + /// + /// Validates the portal suffix carried by a canonical SetPosition + /// projection receipt against this instance's live transit ownership. + /// Shape equality alone is insufficient: a superseded, cancelled, or + /// completed host token can remain structurally valid after it has lost + /// authority to reveal the destination. + /// + public bool IsCurrentPlacementAuthority( + in RuntimePortalPlacementAuthority authority, + uint exactCellId) + { + if (!authority.Present) + return authority.IsEmpty; + + return authority.IsValid + && authority.Projection.DestinationCell == exactCellId + && IsCurrentPortalDestination( + authority.RevealGeneration, + authority.TeleportSequence, + exactCellId) + && TryGetHostProjection( + authority.Projection, + out RuntimeWorldHostProjectionSnapshot host) + && !host.IsSuperseding; + } + /// /// Marks the graphical reservation-release callback as due before the /// host executes it. A thrown callback therefore leaves an exact pending diff --git a/tests/AcDream.App.Tests/World/RuntimePlacementPresentationSinkTests.cs b/tests/AcDream.App.Tests/World/RuntimePlacementPresentationSinkTests.cs new file mode 100644 index 00000000..18ac4a06 --- /dev/null +++ b/tests/AcDream.App.Tests/World/RuntimePlacementPresentationSinkTests.cs @@ -0,0 +1,813 @@ +using System.Numerics; +using AcDream.App.Streaming; +using AcDream.App.World; +using AcDream.App.Physics; +using AcDream.App.Rendering.Vfx; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Core.Plugins; +using AcDream.Core.World; +using AcDream.Runtime; +using AcDream.Runtime.Entities; +using AcDream.Runtime.Physics; +using AcDream.Runtime.World; +using DatReaderWriter.DBObjs; + +namespace AcDream.App.Tests.World; + +public sealed class RuntimePlacementPresentationSinkTests +{ + private const uint SourceCell = 0x01010001u; + private const uint DestinationCell = 0x01020001u; + private const uint Guid = 0x7000A101u; + + [Fact] + public void Place_ReframesAndRebucketsExactSidecarWithoutMutatingRuntimePhysics() + { + Fixture fixture = Fixture.Create(twoLandblocks: true); + LiveEntityRecord record = fixture.Materialize(Spawn(Guid, 1, SourceCell)); + WorldEntity entity = Assert.IsType(record.WorldEntity); + + // Model the already-committed canonical SetPosition edge whose receipt + // the dormant graphical observer is now projecting. + record.FullCellId = DestinationCell; + record.CanonicalLandblockId = + (DestinationCell & 0xFFFF0000u) | 0xFFFFu; + record.Canonical.AdvancePlacementCommit(); + RuntimePlacementProjectionSnapshot place = Placement( + fixture, + record, + RuntimePlacementProjectionKind.Place, + new Vector3(44f, 55f, 66f), + Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.75f)); + + RuntimeOwnershipSnapshot before = RuntimeOwnershipSnapshot.Capture( + fixture.Runtime, + record); + int genericVisibilityCount = 0; + fixture.Runtime.ProjectionVisibilityChanged += (_, _) => + genericVisibilityCount++; + + Assert.True(fixture.Sink.TryApply(in place)); + + Assert.Equal(place.WorldPosition, entity.Position); + Assert.Equal(place.Orientation, entity.Rotation); + Assert.Equal(DestinationCell, entity.ParentCellId); + Assert.True(record.IsSpatiallyProjected); + Assert.True(record.IsSpatiallyVisible); + Assert.Contains(record, fixture.Runtime.VisibleRecords); + Assert.True(fixture.Spatial.IsLiveEntityProjectionResident( + record.ProjectionKey!.Value)); + Assert.Equal(before, RuntimeOwnershipSnapshot.Capture( + fixture.Runtime, + record)); + Assert.Equal(0, genericVisibilityCount); + Assert.Equal( + place.WorldPosition, + Assert.Single(fixture.WorldState.Entities).Position); + Assert.Equal((record, true), Assert.Single(fixture.Visibility)); + Assert.Equal( + new LocalPlayerShadowState.Snapshot( + place.WorldPosition, + place.Orientation, + DestinationCell), + fixture.LocalShadow.Current); + + fixture.Spatial.RemoveLandblock(SourceCell | 0xFFFFu); + Assert.True(fixture.Spatial.IsLiveEntityProjectionResident( + record.ProjectionKey.Value)); + } + + [Fact] + public void Withdraw_RemovesOnlyPresentationAndRetainsLogicalRuntimeOwnership() + { + Fixture fixture = Fixture.Create(); + LiveEntityRecord record = fixture.Materialize(Spawn(Guid, 1, SourceCell)); + RuntimePlacementProjectionSnapshot withdraw = Placement( + fixture, + record, + RuntimePlacementProjectionKind.Withdraw, + record.WorldEntity!.Position, + record.WorldEntity.Rotation); + RuntimeOwnershipSnapshot before = RuntimeOwnershipSnapshot.Capture( + fixture.Runtime, + record); + int genericVisibilityCount = 0; + fixture.Runtime.ProjectionVisibilityChanged += (_, _) => + genericVisibilityCount++; + + Assert.True(fixture.Sink.TryApply(in withdraw)); + + Assert.True(fixture.Runtime.TryGetRecord(Guid, out LiveEntityRecord current)); + Assert.Same(record, current); + Assert.NotNull(record.WorldEntity); + Assert.True(record.ResourcesRegistered); + Assert.False(record.IsSpatiallyProjected); + Assert.False(record.IsSpatiallyVisible); + Assert.DoesNotContain(record, fixture.Runtime.VisibleRecords); + Assert.False(fixture.Spatial.IsLiveEntityProjectionResident( + record.ProjectionKey!.Value)); + Assert.Equal(before, RuntimeOwnershipSnapshot.Capture( + fixture.Runtime, + record)); + Assert.Equal(0, genericVisibilityCount); + Assert.Empty(fixture.WorldState.Entities); + Assert.Equal(0, fixture.EffectPoses.Count); + Assert.Null(fixture.LocalShadow.Current); + Assert.Equal((record, false), Assert.Single(fixture.Visibility)); + Assert.Equal(Guid, Assert.Single(fixture.ClearedSelection)); + } + + [Fact] + public void Discard_IsAckOnlyNoOpEvenWhenTokenIsStaleOrSidecarIsGone() + { + Fixture fixture = Fixture.Create(); + LiveEntityRecord record = fixture.Materialize(Spawn(Guid, 1, SourceCell)); + WorldEntity entity = record.WorldEntity!; + RuntimePlacementProjectionSnapshot discard = Placement( + fixture, + record, + RuntimePlacementProjectionKind.Discard, + new Vector3(900f), + Quaternion.CreateFromAxisAngle(Vector3.UnitX, 1f)) with + { + Token = Placement(fixture, record, + RuntimePlacementProjectionKind.Place, + Vector3.Zero, + Quaternion.Identity).Token with + { + SessionLifetimeVersion = ulong.MaxValue, + PositionAuthorityVersion = ulong.MaxValue, + ExactCellId = 0xDEAD0001u, + }, + }; + Vector3 priorPosition = entity.Position; + Quaternion priorRotation = entity.Rotation; + bool priorVisible = record.IsSpatiallyVisible; + + Assert.True(fixture.Sink.TryApply(in discard)); + + Assert.Equal(priorPosition, entity.Position); + Assert.Equal(priorRotation, entity.Rotation); + Assert.Equal(priorVisible, record.IsSpatiallyVisible); + } + + [Fact] + public void Place_RejectsStaleCanonicalVersionsWithoutChangingSidecar() + { + Fixture fixture = Fixture.Create(); + LiveEntityRecord record = fixture.Materialize(Spawn(Guid, 1, SourceCell)); + WorldEntity entity = record.WorldEntity!; + RuntimePlacementProjectionSnapshot stale = Placement( + fixture, + record, + RuntimePlacementProjectionKind.Place, + new Vector3(90f, 91f, 92f), + Quaternion.CreateFromAxisAngle(Vector3.UnitY, 1f)); + record.Canonical.AdvancePlacementCommit(); + Vector3 priorPosition = entity.Position; + Quaternion priorRotation = entity.Rotation; + + Assert.False(fixture.Sink.TryApply(in stale)); + + Assert.Equal(priorPosition, entity.Position); + Assert.Equal(priorRotation, entity.Rotation); + Assert.True(record.IsSpatiallyVisible); + } + + [Fact] + public void Place_WithoutMaterializedSidecarRemainsPendingForRetry() + { + Fixture fixture = Fixture.Create(); + LiveEntityRegistrationResult registration = fixture.Runtime.RegisterLiveEntity( + Spawn(Guid, 1, SourceCell)); + RuntimeEntityRecord canonical = Assert.IsType( + registration.Canonical); + RuntimeEntityKey missingKey = new(0x60000042u, canonical.Incarnation); + var token = new RuntimePlacementProjectionToken( + Sequence: 1, + Revision: 1, + Entity: missingKey, + PositionAuthorityVersion: canonical.PositionAuthorityVersion, + SpatialAuthorityVersion: canonical.SpatialAuthorityVersion, + PlacementCommitVersion: canonical.PlacementCommitVersion, + SessionLifetimeVersion: fixture.Runtime.SessionLifetimeVersion, + ExactCellId: canonical.FullCellId, + CollisionGeneration: 1, + Portal: default); + var place = new RuntimePlacementProjectionSnapshot( + token, + RuntimePlacementProjectionKind.Place, + new Vector3(1f, 2f, 3f), + Quaternion.Identity, + Vector3.Zero, + InContact: false, + OnWalkable: false); + + Assert.False(fixture.Sink.TryApply(in place)); + Assert.Empty(fixture.Spatial.Entities); + } + + [Fact] + public void Place_WithoutLoadedDestinationBackendRemainsPendingAtPriorProjection() + { + Fixture fixture = Fixture.Create(); + LiveEntityRecord record = fixture.Materialize(Spawn(Guid, 1, SourceCell)); + WorldEntity entity = record.WorldEntity!; + Vector3 priorPosition = entity.Position; + record.FullCellId = DestinationCell; + record.CanonicalLandblockId = + (DestinationCell & 0xFFFF0000u) | 0xFFFFu; + record.Canonical.AdvancePlacementCommit(); + RuntimePlacementProjectionSnapshot place = Placement( + fixture, + record, + RuntimePlacementProjectionKind.Place, + new Vector3(70f, 71f, 72f), + Quaternion.Identity); + + Assert.False(fixture.Sink.TryApply(in place)); + + Assert.Equal(priorPosition, entity.Position); + Assert.True(record.IsSpatiallyVisible); + Assert.True(fixture.Spatial.IsLiveEntityProjectionResident( + record.ProjectionKey!.Value)); + Assert.Empty(fixture.Visibility); + } + + [Fact] + public void Place_UsesExactIncarnationAndCannotMutateSameGuidReplacement() + { + Fixture fixture = Fixture.Create(); + LiveEntityRecord old = fixture.Materialize(Spawn(Guid, 1, SourceCell)); + RuntimePlacementProjectionSnapshot stale = Placement( + fixture, + old, + RuntimePlacementProjectionKind.Place, + new Vector3(88f, 77f, 66f), + Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 1.25f)); + + LiveEntityRecord replacement = fixture.Materialize( + Spawn(Guid, 2, SourceCell)); + WorldEntity replacementEntity = replacement.WorldEntity!; + Vector3 priorPosition = replacementEntity.Position; + Quaternion priorRotation = replacementEntity.Rotation; + + Assert.False(fixture.Sink.TryApply(in stale)); + + Assert.Equal(priorPosition, replacementEntity.Position); + Assert.Equal(priorRotation, replacementEntity.Rotation); + Assert.True(replacement.IsSpatiallyVisible); + } + + [Fact] + public void PortalPlace_RequiresExactCurrentTransitHostAndSequence() + { + Fixture fixture = Fixture.Create(); + LiveEntityRecord record = fixture.Materialize(Spawn(Guid, 1, SourceCell)); + RuntimePlacementProjectionSnapshot ordinary = Placement( + fixture, + record, + RuntimePlacementProjectionKind.Place, + new Vector3(21f, 22f, 23f), + Quaternion.Identity); + RuntimePortalPlacementAuthority authority = fixture.BeginPortal( + SourceCell, + teleportSequence: 9); + RuntimePlacementProjectionSnapshot current = ordinary with + { + Token = ordinary.Token with { Portal = authority }, + }; + + Assert.True(fixture.Sink.TryApply(in current)); + Assert.Equal(current.WorldPosition, record.WorldEntity!.Position); + + Assert.True(fixture.Transit.BeginHostProjectionSupersession( + authority.Projection)); + RuntimePlacementProjectionSnapshot superseded = current with + { + WorldPosition = new Vector3(80f, 81f, 82f), + }; + Assert.False(fixture.Sink.TryApply(in superseded)); + Assert.Equal(current.WorldPosition, record.WorldEntity.Position); + + RuntimePlacementProjectionSnapshot wrongSequence = current with + { + Token = current.Token with + { + Portal = authority with { TeleportSequence = 10 }, + }, + WorldPosition = new Vector3(90f, 91f, 92f), + }; + Assert.False(fixture.Sink.TryApply(in wrongSequence)); + Assert.Equal(current.WorldPosition, record.WorldEntity.Position); + } + + [Fact] + public void PlaceAndWithdraw_DoNotMutateRemoteBodyOrRuntimeOwnership() + { + Fixture fixture = Fixture.Create(); + LiveEntityRecord record = fixture.Materialize(Spawn(Guid, 1, SourceCell)); + RemoteMotion remote = fixture.Runtime.GetOrCreateRemoteMotionRuntime(Guid); + remote.Body.Position = new Vector3(4f, 5f, 6f); + remote.Body.Orientation = Quaternion.CreateFromAxisAngle( + Vector3.UnitY, + 0.4f); + remote.Body.State = PhysicsStateFlags.Gravity + | PhysicsStateFlags.ReportCollisions; + remote.Body.TransientState = TransientStateFlags.Active + | TransientStateFlags.Contact; + remote.Body.InWorld = true; + remote.Body.LastUpdateTime = 42.5; + PhysicsBodySnapshot bodyBefore = PhysicsBodySnapshot.Capture(remote.Body); + RuntimePhysicsOwnershipSnapshot runtimeBefore = + fixture.Runtime.Physics.CaptureOwnership(); + RuntimePlacementProjectionSnapshot place = Placement( + fixture, + record, + RuntimePlacementProjectionKind.Place, + new Vector3(30f, 31f, 32f), + Quaternion.Identity); + + Assert.True(fixture.Sink.TryApply(in place)); + Assert.Equal(bodyBefore, PhysicsBodySnapshot.Capture(remote.Body)); + Assert.Equal(runtimeBefore, fixture.Runtime.Physics.CaptureOwnership()); + + RuntimePlacementProjectionSnapshot withdraw = place with + { + Kind = RuntimePlacementProjectionKind.Withdraw, + }; + Assert.True(fixture.Sink.TryApply(in withdraw)); + Assert.Equal(bodyBefore, PhysicsBodySnapshot.Capture(remote.Body)); + Assert.Equal(runtimeBefore, fixture.Runtime.Physics.CaptureOwnership()); + } + + [Fact] + public void Withdraw_DoesNotMutateProjectileBodyShadowOrWorksetOwnership() + { + Fixture fixture = Fixture.Create(); + LiveEntityRecord record = fixture.Materialize(Spawn(Guid, 1, SourceCell)); + PhysicsBody body = fixture.Runtime.GetOrCreatePhysicsBody( + Guid, + _ => new PhysicsBody()); + body.Position = new Vector3(7f, 8f, 9f); + body.Orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitX, 0.6f); + body.State = PhysicsStateFlags.Missile + | PhysicsStateFlags.ReportCollisions; + body.TransientState = TransientStateFlags.Active; + body.InWorld = true; + body.LastUpdateTime = 99.25; + fixture.Runtime.BindProjectileRuntime( + Guid, + body, + new ProjectileCollisionSphere(Vector3.Zero, 0.25f)); + PhysicsBodySnapshot bodyBefore = PhysicsBodySnapshot.Capture(body); + RuntimePhysicsOwnershipSnapshot runtimeBefore = + fixture.Runtime.Physics.CaptureOwnership(); + RuntimePlacementProjectionSnapshot withdraw = Placement( + fixture, + record, + RuntimePlacementProjectionKind.Withdraw, + record.WorldEntity!.Position, + record.WorldEntity.Rotation); + + Assert.True(fixture.Sink.TryApply(in withdraw)); + + Assert.Equal(bodyBefore, PhysicsBodySnapshot.Capture(body)); + Assert.Equal(runtimeBefore, fixture.Runtime.Physics.CaptureOwnership()); + Assert.Same(body, record.ProjectileRuntime!.Body); + } + + [Fact] + public void FailedPresentationTail_RetriesSameReceiptIdempotently() + { + using var fixture = SubscriptionFixture.Create(); + fixture.VisibilityFailuresRemaining = 1; + using var subscription = new RuntimePlacementProjectionSubscription( + fixture.Lifetime.Placements, + () => fixture.Generation, + fixture.Sink); + + Assert.True(fixture.Lifetime.Physics.SetPosition.Cancel( + fixture.Record.Canonical, + publishWithdrawal: true)); + + Assert.Equal(1, fixture.Lifetime.Placements.PendingCount); + Assert.Equal(1, fixture.Lifetime.Events.DispatchFailureCount); + Assert.IsType( + fixture.Lifetime.Events.LastDispatchFailure); + Assert.False(subscription.HasAppliedReceiptAwaitingAcknowledgement); + + Assert.True(subscription.RetryPending()); + + Assert.Equal(0, fixture.Lifetime.Placements.PendingCount); + Assert.False(fixture.Record.IsSpatiallyProjected); + Assert.Empty(fixture.WorldState.Entities); + Assert.Equal(0, fixture.EffectPoses.Count); + Assert.Equal(2, fixture.Visibility.Count); + Assert.False(subscription.HasAppliedReceiptAwaitingAcknowledgement); + } + + private static RuntimePlacementProjectionSnapshot Placement( + Fixture fixture, + LiveEntityRecord record, + RuntimePlacementProjectionKind kind, + Vector3 position, + Quaternion orientation) + { + RuntimeEntityRecord canonical = record.Canonical; + var token = new RuntimePlacementProjectionToken( + Sequence: 1, + Revision: 1, + Entity: record.ProjectionKey!.Value, + PositionAuthorityVersion: canonical.PositionAuthorityVersion, + SpatialAuthorityVersion: canonical.SpatialAuthorityVersion, + PlacementCommitVersion: canonical.PlacementCommitVersion, + SessionLifetimeVersion: fixture.Runtime.SessionLifetimeVersion, + ExactCellId: canonical.FullCellId, + CollisionGeneration: 1, + Portal: default); + return new RuntimePlacementProjectionSnapshot( + token, + kind, + position, + orientation, + CellLocalPosition: position, + InContact: false, + OnWalkable: false); + } + + private static WorldSession.EntitySpawn Spawn( + uint guid, + ushort instance, + uint cell) + { + var position = new CreateObject.ServerPosition( + cell, 10f, 10f, 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: (uint)PhysicsStateFlags.ReportCollisions, + 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(), + Array.Empty(), + Array.Empty(), + null, + null, + "fixture", + null, + null, + 0x09000001u, + PhysicsState: (uint)PhysicsStateFlags.ReportCollisions, + InstanceSequence: instance, + MovementSequence: 1, + ServerControlSequence: 1, + PositionSequence: 1, + Physics: physics); + } + + private readonly record struct RuntimeOwnershipSnapshot( + ulong PositionAuthorityVersion, + ulong SpatialAuthorityVersion, + ulong PlacementCommitVersion, + ulong ObjectClockEpoch, + bool ObjectClockIsActive, + int SpatialRootCount, + int SpatialRemoteCount, + int SpatialProjectileCount, + RuntimePhysicsOwnershipSnapshot Physics) + { + internal static RuntimeOwnershipSnapshot Capture( + LiveEntityRuntime runtime, + LiveEntityRecord record) => new( + record.Canonical.PositionAuthorityVersion, + record.Canonical.SpatialAuthorityVersion, + record.Canonical.PlacementCommitVersion, + record.ObjectClockEpoch, + record.ObjectClock.IsActive, + runtime.SpatialRootObjectCount, + runtime.SpatialRemoteMotionRuntimeCount, + runtime.SpatialProjectileRuntimeCount, + runtime.Physics.CaptureOwnership()); + } + + private readonly record struct PhysicsBodySnapshot( + Vector3 Position, + Quaternion Orientation, + PhysicsStateFlags State, + TransientStateFlags TransientState, + bool InWorld, + double LastUpdateTime) + { + internal static PhysicsBodySnapshot Capture(PhysicsBody body) => new( + body.Position, + body.Orientation, + body.State, + body.TransientState, + body.InWorld, + body.LastUpdateTime); + } + + private sealed class Fixture + { + private Fixture( + GpuWorldState spatial, + LiveEntityRuntime runtime, + RuntimeWorldTransitState transit, + WorldGameState worldState, + WorldEvents worldEvents, + EntityEffectPoseRegistry effectPoses, + LocalPlayerShadowState localShadow) + { + Spatial = spatial; + Runtime = runtime; + Transit = transit; + WorldState = worldState; + WorldEvents = worldEvents; + EffectPoses = effectPoses; + LocalShadow = localShadow; + Sink = new RuntimePlacementPresentationSink( + runtime, + transit, + worldState, + worldEvents, + effectPoses, + localShadow, + () => Guid, + ClearedSelection.Add, + [ + (record, visible) => + { + Visibility.Add((record, visible)); + if (VisibilityFailuresRemaining > 0) + { + VisibilityFailuresRemaining--; + throw new InvalidOperationException( + "fixture presentation failure"); + } + }, + ]); + } + + internal GpuWorldState Spatial { get; } + internal LiveEntityRuntime Runtime { get; } + internal RuntimeWorldTransitState Transit { get; } + internal WorldGameState WorldState { get; } + internal WorldEvents WorldEvents { get; } + internal EntityEffectPoseRegistry EffectPoses { get; } + internal LocalPlayerShadowState LocalShadow { get; } + internal List<(LiveEntityRecord Record, bool Visible)> Visibility { get; } = []; + internal List ClearedSelection { get; } = []; + internal int VisibilityFailuresRemaining { get; set; } + internal RuntimePlacementPresentationSink Sink { get; } + + internal static Fixture Create(bool twoLandblocks = false) + { + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(SourceCell | 0xFFFFu)); + if (twoLandblocks) + spatial.AddLandblock(EmptyLandblock(DestinationCell | 0xFFFFu)); + var resources = new RecordingResources(); + LiveEntityRuntime runtime = LiveEntityRuntimeFixture.Create( + spatial, + resources); + return new Fixture( + spatial, + runtime, + new RuntimeWorldTransitState(), + new WorldGameState(), + new WorldEvents(), + new EntityEffectPoseRegistry(), + new LocalPlayerShadowState()); + } + + internal LiveEntityRecord Materialize(WorldSession.EntitySpawn spawn) + { + LiveEntityRecord record = Runtime.RegisterAndMaterializeProjection(spawn); + Assert.True(record.ResourcesRegistered); + WorldEntity entity = record.WorldEntity!; + var snapshot = new AcDream.Plugin.Abstractions.WorldEntitySnapshot( + entity.Id, + entity.SourceGfxObjOrSetupId, + entity.Position, + entity.Rotation); + WorldState.Add(snapshot); + WorldEvents.UpsertCurrent(snapshot); + EffectPoses.PublishMeshRefs(entity); + if (record.ServerGuid == Guid) + { + LocalShadow.Set( + entity.Position, + entity.Rotation, + record.FullCellId); + } + return record; + } + + internal RuntimePortalPlacementAuthority BeginPortal( + uint cell, + ushort teleportSequence) + { + Assert.True(Transit.TryQueueTeleportStart(teleportSequence)); + Assert.True(Transit.ActivateQueuedTeleport()); + Assert.True(Transit.OfferTeleportDestination( + new RuntimeTeleportDestination( + Guid, + InstanceSequence: 1, + PositionSequence: 1, + TeleportSequence: teleportSequence, + ForcePositionSequence: 1, + new Position( + cell, + new Vector3(1f, 2f, 3f), + Quaternion.Identity)), + teleportTimestampAdvanced: true)); + Assert.True(Transit.TryBeginPortalReveal( + teleportSequence, + cell, + out long generation)); + Assert.True(Transit.TryRegisterHostProjection( + generation, + cell, + out RuntimeWorldHostProjectionToken host)); + return new RuntimePortalPlacementAuthority( + true, + generation, + teleportSequence, + host); + } + + private static LoadedLandblock EmptyLandblock(uint canonicalId) => + new(canonicalId, new LandBlock(), Array.Empty()); + } + + private sealed class SubscriptionFixture : IDisposable + { + private SubscriptionFixture( + RuntimeEntityObjectLifetime lifetime, + LiveEntityRuntime runtime, + LiveEntityRecord record, + RuntimePlacementPresentationSink sink, + WorldGameState worldState, + EntityEffectPoseRegistry effectPoses, + List<(LiveEntityRecord Record, bool Visible)> visibility) + { + Lifetime = lifetime; + Runtime = runtime; + Record = record; + Sink = sink; + WorldState = worldState; + EffectPoses = effectPoses; + Visibility = visibility; + } + + internal RuntimeGenerationToken Generation { get; } = new(7UL); + internal RuntimeEntityObjectLifetime Lifetime { get; } + internal LiveEntityRuntime Runtime { get; } + internal LiveEntityRecord Record { get; } + internal RuntimePlacementPresentationSink Sink { get; } + internal WorldGameState WorldState { get; } + internal EntityEffectPoseRegistry EffectPoses { get; } + internal List<(LiveEntityRecord Record, bool Visible)> Visibility { get; } + internal int VisibilityFailuresRemaining { get; set; } + + internal static SubscriptionFixture Create() + { + PhysicsEngine engine = FlatEngine(); + var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeGenerationToken generation = new(7UL); + lifetime.BindEventContext(() => generation, static () => 11UL); + var spatial = new GpuWorldState(); + spatial.AddLandblock(new LoadedLandblock( + SourceCell | 0xFFFFu, + new LandBlock(), + Array.Empty())); + var runtime = new LiveEntityRuntime( + spatial, + new RecordingResources(), + lifetime); + LiveEntityRecord record = runtime.RegisterAndMaterializeProjection( + Spawn(Guid, 1, SourceCell)); + PhysicsBody body = runtime.GetOrCreatePhysicsBody( + Guid, + _ => new PhysicsBody + { + Position = new Vector3(10f, 10f, 5f), + Orientation = Quaternion.Identity, + LastUpdateTime = 1d, + State = PhysicsStateFlags.ReportCollisions, + TransientState = TransientStateFlags.Active, + }); + body.SnapToCell( + SourceCell, + body.Position, + body.Position); + + var worldState = new WorldGameState(); + var worldEvents = new WorldEvents(); + var effectPoses = new EntityEffectPoseRegistry(); + var localShadow = new LocalPlayerShadowState(); + WorldEntity entity = record.WorldEntity!; + var snapshot = new AcDream.Plugin.Abstractions.WorldEntitySnapshot( + entity.Id, + entity.SourceGfxObjOrSetupId, + entity.Position, + entity.Rotation); + worldState.Add(snapshot); + worldEvents.UpsertCurrent(snapshot); + effectPoses.PublishMeshRefs(entity); + localShadow.Set(entity.Position, entity.Rotation, record.FullCellId); + var visibility = new List<(LiveEntityRecord Record, bool Visible)>(); + SubscriptionFixture? fixture = null; + var sink = new RuntimePlacementPresentationSink( + runtime, + new RuntimeWorldTransitState(), + worldState, + worldEvents, + effectPoses, + localShadow, + () => Guid, + _ => { }, + [ + (candidate, visible) => + { + visibility.Add((candidate, visible)); + if (fixture!.VisibilityFailuresRemaining > 0) + { + fixture.VisibilityFailuresRemaining--; + throw new InvalidOperationException( + "fixture presentation failure"); + } + }, + ]); + fixture = new SubscriptionFixture( + lifetime, + runtime, + record, + sink, + worldState, + effectPoses, + visibility); + return fixture; + } + + public void Dispose() + { + Runtime.Clear(); + Lifetime.Dispose(); + } + + private static PhysicsEngine FlatEngine() + { + var engine = new PhysicsEngine + { + DataCache = new PhysicsDataCache(), + }; + engine.AddLandblock( + SourceCell & 0xFFFF0000u, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + return engine; + } + } + + private sealed class RecordingResources : ILiveEntityResourceLifecycle + { + public void Register(WorldEntity entity) { } + public void Unregister(WorldEntity entity) { } + } +} diff --git a/tests/AcDream.Runtime.Tests/World/RuntimeWorldTransitStateTests.cs b/tests/AcDream.Runtime.Tests/World/RuntimeWorldTransitStateTests.cs index b4c5d0ac..37561c95 100644 --- a/tests/AcDream.Runtime.Tests/World/RuntimeWorldTransitStateTests.cs +++ b/tests/AcDream.Runtime.Tests/World/RuntimeWorldTransitStateTests.cs @@ -1,5 +1,6 @@ using System.Numerics; using AcDream.Core.Physics; +using AcDream.Runtime.Physics; using AcDream.Runtime.World; namespace AcDream.Runtime.Tests.World; @@ -9,6 +10,77 @@ public sealed class RuntimeWorldTransitStateTests private const uint OutdoorCell = 0x11340021u; private const uint OtherCell = 0x3032001Cu; + [Fact] + public void PlacementAuthority_RequiresCurrentPortalHostSequenceAndReveal() + { + var state = new RuntimeWorldTransitState(); + long generation = BeginPortal(state, OutdoorCell, sequence: 7); + RuntimeWorldHostProjectionToken host = + RegisterHost(state, generation, OutdoorCell); + RuntimePortalPlacementAuthority authority = new( + Present: true, + RevealGeneration: generation, + TeleportSequence: 7, + Projection: host); + + Assert.True(state.IsCurrentPlacementAuthority( + authority, + OutdoorCell)); + Assert.True(state.IsCurrentPlacementAuthority(default, OutdoorCell)); + Assert.False(state.IsCurrentPlacementAuthority( + authority with { TeleportSequence = 8 }, + OutdoorCell)); + Assert.False(state.IsCurrentPlacementAuthority( + authority, + OtherCell)); + + Assert.True(state.BeginHostProjectionSupersession(host)); + Assert.False(state.IsCurrentPlacementAuthority( + authority, + OutdoorCell)); + } + + [Fact] + public void PlacementAuthority_RejectsCancelledAndCompletedReveals() + { + var cancelled = new RuntimeWorldTransitState(); + long cancelledGeneration = BeginPortal(cancelled, OutdoorCell); + RuntimeWorldHostProjectionToken cancelledHost = + RegisterHost(cancelled, cancelledGeneration, OutdoorCell); + RuntimePortalPlacementAuthority cancelledAuthority = new( + true, + cancelledGeneration, + 1, + cancelledHost); + Assert.True(cancelled.Cancel(cancelledGeneration)); + Assert.False(cancelled.IsCurrentPlacementAuthority( + cancelledAuthority, + OutdoorCell)); + + var completed = new RuntimeWorldTransitState(); + long completedGeneration = BeginPortal(completed, OutdoorCell); + RuntimeWorldHostProjectionToken completedHost = + RegisterHost(completed, completedGeneration, OutdoorCell); + RuntimePortalPlacementAuthority completedAuthority = new( + true, + completedGeneration, + 1, + completedHost); + Assert.True(completed.AcknowledgeDestinationReadiness( + Ready(completedGeneration, OutdoorCell))); + Assert.True(completed.AcknowledgePortalMaterialized( + completedGeneration, + 1, + OutdoorCell)); + Assert.True(completed.AcknowledgeWorldViewportVisible( + completedGeneration)); + Assert.True(completed.Complete(completedGeneration)); + + Assert.False(completed.IsCurrentPlacementAuthority( + completedAuthority, + OutdoorCell)); + } + [Fact] public void BeginReveal_OwnsGenerationDestinationAndSimulationGate() {