From 18d17d8bb17ed9ff3ed01da8273e2948abe6398b Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 26 Jul 2026 18:27:41 +0200 Subject: [PATCH] refactor(runtime): acknowledge exact world host projections --- .../Composition/FrameRootComposition.cs | 2 + .../WorldLifecycleAutomationController.cs | 17 + .../Runtime/CurrentGameRuntimeViewAdapter.cs | 4 +- .../Streaming/WorldGenerationQuiescence.cs | 55 ++- .../Streaming/WorldRevealCoordinator.cs | 349 ++++++++++++++-- src/AcDream.Runtime/GameRuntimeEvents.cs | 13 +- src/AcDream.Runtime/GameRuntimeViews.cs | 54 ++- .../World/RuntimeWorldEnvironmentState.cs | 15 + .../World/RuntimeWorldTransitState.cs | 386 +++++++++++++++++- ...WorldLifecycleAutomationControllerTests.cs | 25 ++ .../RuntimeWorldTransitOwnershipTests.cs | 29 +- .../WorldGenerationQuiescenceTests.cs | 48 +++ .../Streaming/WorldRevealCoordinatorTests.cs | 276 ++++++++++++- .../GameRuntimeContractTests.cs | 18 +- .../RuntimeWorldTransitDirectHostTests.cs | 204 +++++++++ .../World/RuntimeWorldTransitStateTests.cs | 227 ++++++++++ tools/run-connected-world-lifecycle-gate.ps1 | 27 ++ 17 files changed, 1677 insertions(+), 72 deletions(-) create mode 100644 tests/AcDream.Runtime.Tests/World/RuntimeWorldTransitDirectHostTests.cs diff --git a/src/AcDream.App/Composition/FrameRootComposition.cs b/src/AcDream.App/Composition/FrameRootComposition.cs index 337b54df..ac69b292 100644 --- a/src/AcDream.App/Composition/FrameRootComposition.cs +++ b/src/AcDream.App/Composition/FrameRootComposition.cs @@ -453,6 +453,8 @@ internal sealed class FrameRootCompositionPhase lifecycleAutomation = new WorldLifecycleAutomationController( () => session.WorldReveal.Snapshot, + () => d.WorldEnvironment.Runtime.Ownership, + () => live.WorldTransit.Ownership, () => session.WorldReveal.PortalMaterializationCount, resourceSnapshots.Capture, screenshots, diff --git a/src/AcDream.App/Diagnostics/WorldLifecycleAutomationController.cs b/src/AcDream.App/Diagnostics/WorldLifecycleAutomationController.cs index e0050b3d..63d7ee32 100644 --- a/src/AcDream.App/Diagnostics/WorldLifecycleAutomationController.cs +++ b/src/AcDream.App/Diagnostics/WorldLifecycleAutomationController.cs @@ -6,6 +6,7 @@ using AcDream.App.Streaming; using AcDream.App.UI.Testing; using AcDream.Core.Physics; using AcDream.Runtime; +using AcDream.Runtime.World; namespace AcDream.App.Diagnostics; @@ -103,6 +104,8 @@ internal sealed record WorldLifecycleCheckpoint( DateTime TimestampUtc, int ProcessId, RuntimePortalSnapshot Reveal, + RuntimeWorldEnvironmentOwnershipSnapshot EnvironmentOwnership, + RuntimeWorldTransitOwnershipSnapshot TransitOwnership, RenderFrameOutcome Render, WorldLifecycleResourceSnapshot Resources); @@ -177,6 +180,10 @@ internal sealed class WorldLifecycleAutomationController : }; private readonly Func _getReveal; + private readonly Func + _getEnvironmentOwnership; + private readonly Func + _getTransitOwnership; private readonly Func _getPortalMaterializationCount; private readonly Func _captureResources; @@ -191,6 +198,9 @@ internal sealed class WorldLifecycleAutomationController : public WorldLifecycleAutomationController( Func getReveal, + Func + getEnvironmentOwnership, + Func getTransitOwnership, Func getPortalMaterializationCount, Func captureResources, FrameScreenshotController screenshots, @@ -198,6 +208,11 @@ internal sealed class WorldLifecycleAutomationController : Action? log = null) { _getReveal = getReveal ?? throw new ArgumentNullException(nameof(getReveal)); + _getEnvironmentOwnership = getEnvironmentOwnership + ?? throw new ArgumentNullException( + nameof(getEnvironmentOwnership)); + _getTransitOwnership = getTransitOwnership + ?? throw new ArgumentNullException(nameof(getTransitOwnership)); _getPortalMaterializationCount = getPortalMaterializationCount ?? throw new ArgumentNullException(nameof(getPortalMaterializationCount)); _captureResources = captureResources ?? throw new ArgumentNullException(nameof(captureResources)); @@ -289,6 +304,8 @@ internal sealed class WorldLifecycleAutomationController : TimestampUtc: DateTime.UtcNow, ProcessId: Environment.ProcessId, Reveal: _getReveal(), + EnvironmentOwnership: _getEnvironmentOwnership(), + TransitOwnership: _getTransitOwnership(), Render: outcome, Resources: _captureResources(outcome)); string json = JsonSerializer.Serialize(checkpoint, JsonOptions); diff --git a/src/AcDream.App/Runtime/CurrentGameRuntimeViewAdapter.cs b/src/AcDream.App/Runtime/CurrentGameRuntimeViewAdapter.cs index 677ef066..bfbcdddb 100644 --- a/src/AcDream.App/Runtime/CurrentGameRuntimeViewAdapter.cs +++ b/src/AcDream.App/Runtime/CurrentGameRuntimeViewAdapter.cs @@ -126,7 +126,9 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView _actionView.Snapshot, _movementView.Snapshot, _environmentView.Snapshot, - _portalView.Snapshot); + _environmentView.Ownership, + _portalView.Snapshot, + _portalView.Ownership); internal void Deactivate() => _active = false; } diff --git a/src/AcDream.App/Streaming/WorldGenerationQuiescence.cs b/src/AcDream.App/Streaming/WorldGenerationQuiescence.cs index 4653540c..5ebfd3e1 100644 --- a/src/AcDream.App/Streaming/WorldGenerationQuiescence.cs +++ b/src/AcDream.App/Streaming/WorldGenerationQuiescence.cs @@ -63,6 +63,9 @@ internal sealed class WorldGenerationQuiescence private readonly Func _resolveProjectionKey; private readonly IWorldAudioQuiescence? _audio; private bool _effectsQuiesced; + private bool _selectionEdgeCommitted; + private bool _audioSuspended; + private bool _audioTransitionActive; public WorldGenerationQuiescence( SelectionState selection, @@ -99,26 +102,62 @@ internal sealed class WorldGenerationQuiescence public void CommitBegin(in WorldGenerationQuiescenceEdge edge) { - if (!edge.ShouldApply || _effectsQuiesced) + if (!edge.ShouldApply) return; _effectsQuiesced = true; - if (edge.ClearWorldSelection) + if (!_selectionEdgeCommitted) { - _selection.Clear( - SelectionChangeSource.System, - SelectionChangeReason.Cleared); + // SelectionState commits its value before notifying observers. + // Record the edge first so a re-entrant reveal cannot replay it. + _selectionEdgeCommitted = true; + if (edge.ClearWorldSelection) + { + _selection.Clear( + SelectionChangeSource.System, + SelectionChangeReason.Cleared); + } } - _audio?.SuspendWorldAudio(); + ReconcileAudio(); } public void ObserveReleased() { - if (!_effectsQuiesced) + if (!_effectsQuiesced + && !_selectionEdgeCommitted + && !_audioSuspended) + { return; + } _effectsQuiesced = false; - _audio?.ResumeWorldAudio(); + ReconcileAudio(); + if (!_effectsQuiesced && !_audioSuspended) + _selectionEdgeCommitted = false; + } + + private void ReconcileAudio() + { + if (_audio is null || _audioTransitionActive) + return; + + _audioTransitionActive = true; + try + { + while (_audioSuspended != _effectsQuiesced) + { + bool suspend = _effectsQuiesced; + if (suspend) + _audio.SuspendWorldAudio(); + else + _audio.ResumeWorldAudio(); + _audioSuspended = suspend; + } + } + finally + { + _audioTransitionActive = false; + } } } diff --git a/src/AcDream.App/Streaming/WorldRevealCoordinator.cs b/src/AcDream.App/Streaming/WorldRevealCoordinator.cs index 99124800..6dd48fd6 100644 --- a/src/AcDream.App/Streaming/WorldRevealCoordinator.cs +++ b/src/AcDream.App/Streaming/WorldRevealCoordinator.cs @@ -37,13 +37,31 @@ internal interface IWorldRevealRenderResourceScheduler /// internal sealed class WorldRevealCoordinator { + private sealed class HostProjection( + RuntimeWorldHostProjectionToken token, + int requiredRenderRadius, + in WorldGenerationQuiescenceEdge quiescenceEdge) + { + public RuntimeWorldHostProjectionToken Token { get; } = token; + public int RequiredRenderRadius { get; } = requiredRenderRadius; + public WorldGenerationQuiescenceEdge QuiescenceEdge { get; } = + quiescenceEdge; + public bool QuiescenceCommitted { get; set; } + public bool StreamingRegistered { get; set; } + public bool RenderResourcesRegistered { get; set; } + public bool SimulationReleaseProjected { get; set; } + public bool StreamingReleased { get; set; } + public bool RenderResourcesReleased { get; set; } + } + private readonly RuntimeWorldTransitState _transit; private readonly WorldRevealReadinessBarrier _readiness; private readonly WorldGenerationQuiescence? _quiescence; private readonly IWorldRevealStreamingScheduler? _streaming; private readonly IWorldRevealRenderResourceScheduler? _renderResources; - private long _reservationGeneration; - private bool _worldViewportReleased; + private readonly List _hostProjections = []; + private bool _hostRetryActive; + private bool _hostRetryRequested; public WorldRevealCoordinator( RuntimeWorldTransitState transit, @@ -76,12 +94,15 @@ internal sealed class WorldRevealCoordinator public int PortalMaterializationCount => _transit.Snapshot.PortalMaterializationCount; public bool WaitCueShown => _transit.Snapshot.WaitCueShown; + public RuntimeWorldTransitOwnershipSnapshot Ownership => + _transit.Ownership; public long BeginLogin(uint destinationCell) { if (destinationCell == 0u) throw new ArgumentOutOfRangeException(nameof(destinationCell)); + WithdrawHostForReplacement(); WorldGenerationQuiescenceEdge quiescenceEdge = _quiescence?.CaptureBegin() ?? default; _readiness.Begin(); @@ -100,7 +121,15 @@ internal sealed class WorldRevealCoordinator { if (destinationCell == 0u) throw new ArgumentOutOfRangeException(nameof(destinationCell)); + if (!_transit.CanBeginPortalReveal( + teleportSequence, + destinationCell)) + { + generation = 0; + return false; + } + WithdrawHostForReplacement(); WorldGenerationQuiescenceEdge quiescenceEdge = _quiescence?.CaptureBegin() ?? default; if (!_transit.TryBeginPortalReveal( @@ -124,14 +153,21 @@ internal sealed class WorldRevealCoordinator uint destinationCell, in WorldGenerationQuiescenceEdge quiescenceEdge) { - _quiescence?.CommitBegin(quiescenceEdge); - _reservationGeneration = generation; - _worldViewportReleased = false; - _streaming?.BeginDestinationReservation( - generation, - destinationCell, - WorldRevealReadinessBarrier.RequiredRenderRadius(destinationCell)); - _renderResources?.BeginDestinationReveal(generation); + if (!_transit.TryRegisterHostProjection( + generation, + destinationCell, + out RuntimeWorldHostProjectionToken token)) + { + throw new InvalidOperationException( + "Runtime rejected the exact reveal host projection."); + } + + _hostProjections.Add(new HostProjection( + token, + WorldRevealReadinessBarrier.RequiredRenderRadius( + destinationCell), + quiescenceEdge)); + RetryPendingHostWork(); } /// @@ -146,6 +182,7 @@ internal sealed class WorldRevealCoordinator public WorldRevealReadinessSnapshot Evaluate(uint destinationCell) { + RetryPendingHostWork(); WorldRevealReadinessSnapshot snapshot = _readiness.Evaluate(destinationCell); RuntimePortalSnapshot portal = _transit.Snapshot; if (portal.Generation != 0) @@ -189,17 +226,17 @@ internal sealed class WorldRevealCoordinator ushort teleportSequence, uint destinationCell) { - bool wasAvailable = _transit.IsWorldSimulationAvailable; bool acknowledged = _transit.AcknowledgePortalMaterialized( generation, teleportSequence, destinationCell); - ObserveSimulationRelease(wasAvailable); + RetryPendingHostWork(); return acknowledged; } public void ObserveWorldViewportVisible() { + RetryPendingHostWork(); long generation = _transit.Snapshot.Generation; if (generation != 0) _transit.AcknowledgeWorldViewportVisible(generation); @@ -220,13 +257,12 @@ internal sealed class WorldRevealCoordinator /// public void RevealWorldViewport() { - long generation = _reservationGeneration; - if (generation == 0 || _worldViewportReleased) + HostProjection? host = FindCurrentHostProjection(); + if (host is null) return; - _streaming?.EndDestinationReservation(generation); - _renderResources?.EndDestinationReveal(generation); - _worldViewportReleased = true; + _transit.RequireDestinationReservationRelease(host.Token); + RetryPendingHostWork(); } public void Complete() @@ -235,10 +271,8 @@ internal sealed class WorldRevealCoordinator if (generation == 0) return; - bool wasAvailable = _transit.IsWorldSimulationAvailable; _transit.Complete(generation); - ObserveSimulationRelease(wasAvailable); - EndHostLifetime(); + RetryPendingHostWork(); } public void Cancel() @@ -247,37 +281,284 @@ internal sealed class WorldRevealCoordinator if (generation == 0) return; - bool wasAvailable = _transit.IsWorldSimulationAvailable; _transit.Cancel(generation); - ObserveSimulationRelease(wasAvailable); - EndHostLifetime(); + RetryPendingHostWork(); } public void ResetSession() { - bool wasAvailable = _transit.IsWorldSimulationAvailable; long generation = _transit.Snapshot.Generation; if (generation != 0) _transit.Cancel(generation); - ObserveSimulationRelease(wasAvailable); - EndHostLifetime(); + RetryPendingHostWork(); _transit.ResetSession(); } - private void EndHostLifetime() + /// + /// Resumes the exact unfinished graphical-host suffix. Completed callback + /// stages never replay; a thrown callback leaves its Runtime + /// acknowledgement pending for the next call. + /// + internal void RetryPendingHostWork() { - long generation = _reservationGeneration; - if (generation == 0) + _hostRetryRequested = true; + if (_hostRetryActive) return; - RevealWorldViewport(); - _reservationGeneration = 0; - _worldViewportReleased = false; + _hostRetryActive = true; + try + { + do + { + _hostRetryRequested = false; + int index = 0; + while (index < _hostProjections.Count) + { + HostProjection host = _hostProjections[index]; + DrainHostProjection(host); + if (index < _hostProjections.Count + && ReferenceEquals( + _hostProjections[index], + host)) + { + index++; + } + } + } + while (_hostRetryRequested); + } + finally + { + _hostRetryActive = false; + } } - private void ObserveSimulationRelease(bool wasAvailable) + private void DrainHostProjection(HostProjection host) { - if (!wasAvailable && _transit.IsWorldSimulationAvailable) + if (!TryGetHostProjection(host, out var projection)) + return; + + RuntimeWorldHostAcknowledgementStage pending = + projection.PendingAcknowledgements; + if ((pending & RuntimeWorldHostAcknowledgementStage + .ProjectionRegistered) != 0) + { + CompleteHostRegistration(host); + } + + if (!TryGetHostProjection(host, out projection)) + return; + pending = projection.PendingAcknowledgements; + if ((pending & RuntimeWorldHostAcknowledgementStage + .SimulationReleaseProjected) != 0) + { + CompleteSimulationRelease(host); + } + + if (!TryGetHostProjection(host, out projection)) + return; + pending = projection.PendingAcknowledgements; + if ((pending & RuntimeWorldHostAcknowledgementStage + .DestinationReservationReleased) != 0) + { + CompleteDestinationReservationRelease(host); + } + + if (!TryGetHostProjection(host, out projection)) + return; + if ((projection.PendingAcknowledgements + & RuntimeWorldHostAcknowledgementStage.TerminalProjected) + == 0) + { + return; + } + + Acknowledge( + host, + RuntimeWorldHostAcknowledgementStage.TerminalProjected); + RemoveHostProjection(host); + } + + private void CompleteHostRegistration(HostProjection host) + { + if (!host.QuiescenceCommitted) + { + _quiescence?.CommitBegin(host.QuiescenceEdge); + host.QuiescenceCommitted = true; + } + if (!IsStagePending( + host, + RuntimeWorldHostAcknowledgementStage + .ProjectionRegistered)) + { + return; + } + if (!host.StreamingRegistered) + { + _streaming?.BeginDestinationReservation( + host.Token.Generation, + host.Token.DestinationCell, + host.RequiredRenderRadius); + host.StreamingRegistered = true; + } + if (!IsStagePending( + host, + RuntimeWorldHostAcknowledgementStage + .ProjectionRegistered)) + { + return; + } + if (!host.RenderResourcesRegistered) + { + _renderResources?.BeginDestinationReveal( + host.Token.Generation); + host.RenderResourcesRegistered = true; + } + if (!IsStagePending( + host, + RuntimeWorldHostAcknowledgementStage + .ProjectionRegistered)) + { + return; + } + + Acknowledge( + host, + RuntimeWorldHostAcknowledgementStage.ProjectionRegistered); + } + + private void CompleteSimulationRelease(HostProjection host) + { + if (!host.SimulationReleaseProjected) + { _quiescence?.ObserveReleased(); + host.SimulationReleaseProjected = true; + } + if (!IsStagePending( + host, + RuntimeWorldHostAcknowledgementStage + .SimulationReleaseProjected)) + { + return; + } + + Acknowledge( + host, + RuntimeWorldHostAcknowledgementStage + .SimulationReleaseProjected); + } + + private void CompleteDestinationReservationRelease(HostProjection host) + { + if (!host.StreamingReleased) + { + if (host.StreamingRegistered) + { + _streaming?.EndDestinationReservation( + host.Token.Generation); + } + host.StreamingReleased = true; + } + if (!IsStagePending( + host, + RuntimeWorldHostAcknowledgementStage + .DestinationReservationReleased)) + { + return; + } + if (!host.RenderResourcesReleased) + { + if (host.RenderResourcesRegistered) + { + _renderResources?.EndDestinationReveal( + host.Token.Generation); + } + host.RenderResourcesReleased = true; + } + if (!IsStagePending( + host, + RuntimeWorldHostAcknowledgementStage + .DestinationReservationReleased)) + { + return; + } + + Acknowledge( + host, + RuntimeWorldHostAcknowledgementStage + .DestinationReservationReleased); + } + + private void WithdrawHostForReplacement() + { + HostProjection? host = FindCurrentHostProjection(); + if (host is null) + return; + + if (!_transit.BeginHostProjectionSupersession(host.Token)) + { + throw new InvalidOperationException( + "Runtime rejected reveal-host supersession."); + } + RetryPendingHostWork(); + } + + private HostProjection? FindCurrentHostProjection() + { + long generation = _transit.Snapshot.Generation; + for (int i = 0; i < _hostProjections.Count; i++) + { + HostProjection candidate = _hostProjections[i]; + if (candidate.Token.Generation == generation) + return candidate; + } + + return null; + } + + private bool TryGetHostProjection( + HostProjection host, + out RuntimeWorldHostProjectionSnapshot projection) + { + if (_transit.TryGetHostProjection(host.Token, out projection)) + return true; + + RemoveHostProjection(host); + return false; + } + + private bool IsStagePending( + HostProjection host, + RuntimeWorldHostAcknowledgementStage stage) => + _transit.TryGetHostProjection( + host.Token, + out RuntimeWorldHostProjectionSnapshot projection) + && (projection.PendingAcknowledgements & stage) != 0; + + private void RemoveHostProjection(HostProjection host) + { + for (int i = 0; i < _hostProjections.Count; i++) + { + if (!ReferenceEquals(_hostProjections[i], host)) + continue; + + _hostProjections.RemoveAt(i); + return; + } + } + + private void Acknowledge( + HostProjection host, + RuntimeWorldHostAcknowledgementStage stage) + { + if (!_transit.AcknowledgeHostProjection( + new RuntimeWorldHostAcknowledgement( + host.Token, + stage))) + { + throw new InvalidOperationException( + $"Runtime rejected reveal host acknowledgement {stage} " + + $"for generation {host.Token.Generation}."); + } } } diff --git a/src/AcDream.Runtime/GameRuntimeEvents.cs b/src/AcDream.Runtime/GameRuntimeEvents.cs index d0e54246..b2d03b1d 100644 --- a/src/AcDream.Runtime/GameRuntimeEvents.cs +++ b/src/AcDream.Runtime/GameRuntimeEvents.cs @@ -192,8 +192,19 @@ public sealed class RuntimeTraceRecorder : IRuntimeEventObserver $"{checkpoint.Environment.ActiveDayGroupIndex}:" + $"{(int)checkpoint.Environment.Weather}:" + $"{(int)checkpoint.Environment.EnvironOverride};" + + $"environment-owner={checkpoint.EnvironmentOwnership.IsInitialized}:" + + $"{checkpoint.EnvironmentOwnership.DayGroupDefinitionCount}:" + + $"{checkpoint.EnvironmentOwnership.ActiveDayGroupCount};" + $"portal={checkpoint.Portal.Generation}:{(int)checkpoint.Portal.Kind}:" + - $"{checkpoint.Portal.DestinationCell:X8}:{checkpoint.Portal.IsReady}"))); + $"{checkpoint.Portal.DestinationCell:X8}:{checkpoint.Portal.IsReady};" + + $"transit-owner={checkpoint.TransitOwnership.BufferedTeleportDestinationCount}:" + + $"{checkpoint.TransitOwnership.PendingTeleportStartCount}:" + + $"{checkpoint.TransitOwnership.ActiveTeleportCount}:" + + $"{checkpoint.TransitOwnership.AcceptedTeleportDestinationCount}:" + + $"{checkpoint.TransitOwnership.ActiveRevealCount}:" + + $"{checkpoint.TransitOwnership.PendingDestinationReadinessCount}:" + + $"{checkpoint.TransitOwnership.HostProjectionCount}:" + + $"{checkpoint.TransitOwnership.PendingHostAcknowledgementCount}"))); } public void OnLifecycle(in RuntimeLifecycleDelta delta) => diff --git a/src/AcDream.Runtime/GameRuntimeViews.cs b/src/AcDream.Runtime/GameRuntimeViews.cs index fe04bac3..bccbfc33 100644 --- a/src/AcDream.Runtime/GameRuntimeViews.cs +++ b/src/AcDream.Runtime/GameRuntimeViews.cs @@ -128,6 +128,55 @@ public readonly record struct RuntimeDestinationReadiness( && IsCollisionReady)); } +[Flags] +public enum RuntimeWorldHostAcknowledgementStage +{ + None = 0, + ProjectionRegistered = 1 << 0, + SimulationReleaseProjected = 1 << 1, + DestinationReservationReleased = 1 << 2, + TerminalProjected = 1 << 3, +} + +/// +/// Exact identity of one graphical or direct host projection attached to a +/// Runtime reveal generation. It contains no presentation object. +/// +public readonly record struct RuntimeWorldHostProjectionToken( + long Generation, + uint DestinationCell) +{ + public bool IsValid => Generation != 0 && DestinationCell != 0u; +} + +public readonly record struct RuntimeWorldHostAcknowledgement( + RuntimeWorldHostProjectionToken Projection, + RuntimeWorldHostAcknowledgementStage Stage); + +public readonly record struct RuntimeWorldHostProjectionSnapshot( + RuntimeWorldHostProjectionToken Token, + RuntimeWorldHostAcknowledgementStage PendingAcknowledgements, + bool ProjectionRegistered, + bool SimulationReleaseProjected, + bool DestinationReservationReleased, + bool IsSuperseding) +{ + public int PendingAcknowledgementCount => + Count(PendingAcknowledgements); + + private static int Count(RuntimeWorldHostAcknowledgementStage stages) + { + int value = (int)stages; + int count = 0; + while (value != 0) + { + value &= value - 1; + count++; + } + return count; + } +} + public readonly record struct RuntimePortalSnapshot( long Generation, RuntimePortalKind Kind, @@ -166,6 +215,7 @@ public readonly record struct RuntimePortalSnapshot( public interface IRuntimePortalView { RuntimePortalSnapshot Snapshot { get; } + RuntimeWorldTransitOwnershipSnapshot Ownership { get; } } public readonly record struct RuntimeStateCheckpoint( @@ -184,7 +234,9 @@ public readonly record struct RuntimeStateCheckpoint( RuntimeActionSnapshot Actions, RuntimeMovementSnapshot Movement, RuntimeWorldEnvironmentSnapshot Environment, - RuntimePortalSnapshot Portal); + RuntimeWorldEnvironmentOwnershipSnapshot EnvironmentOwnership, + RuntimePortalSnapshot Portal, + RuntimeWorldTransitOwnershipSnapshot TransitOwnership); public interface IGameRuntimeView { diff --git a/src/AcDream.Runtime/World/RuntimeWorldEnvironmentState.cs b/src/AcDream.Runtime/World/RuntimeWorldEnvironmentState.cs index bb63e94c..d681a826 100644 --- a/src/AcDream.Runtime/World/RuntimeWorldEnvironmentState.cs +++ b/src/AcDream.Runtime/World/RuntimeWorldEnvironmentState.cs @@ -96,9 +96,15 @@ public readonly record struct RuntimeWorldEnvironmentSnapshot( WeatherKind Weather, EnvironOverride EnvironOverride); +public readonly record struct RuntimeWorldEnvironmentOwnershipSnapshot( + bool IsInitialized, + int DayGroupDefinitionCount, + int ActiveDayGroupCount); + public interface IRuntimeWorldEnvironmentView { RuntimeWorldEnvironmentSnapshot Snapshot { get; } + RuntimeWorldEnvironmentOwnershipSnapshot Ownership { get; } } /// @@ -152,6 +158,15 @@ public sealed class RuntimeWorldEnvironmentState Weather.Kind, Weather.Override); + public RuntimeWorldEnvironmentOwnershipSnapshot Ownership => + CaptureOwnership(); + + public RuntimeWorldEnvironmentOwnershipSnapshot CaptureOwnership() => + new( + IsInitialized, + _definition?.DayGroups.Count ?? 0, + ActiveDayGroupIndex >= 0 ? 1 : 0); + public void Initialize(RuntimeWorldEnvironmentDefinition definition) { ArgumentNullException.ThrowIfNull(definition); diff --git a/src/AcDream.Runtime/World/RuntimeWorldTransitState.cs b/src/AcDream.Runtime/World/RuntimeWorldTransitState.cs index 0bf0dbf8..ebcad65d 100644 --- a/src/AcDream.Runtime/World/RuntimeWorldTransitState.cs +++ b/src/AcDream.Runtime/World/RuntimeWorldTransitState.cs @@ -2,6 +2,27 @@ using System.Globalization; namespace AcDream.Runtime.World; +public readonly record struct RuntimeWorldTransitOwnershipSnapshot( + int BufferedTeleportDestinationCount, + int PendingTeleportStartCount, + int ActiveTeleportCount, + int AcceptedTeleportDestinationCount, + int ActiveRevealCount, + int PendingDestinationReadinessCount, + int HostProjectionCount, + int PendingHostAcknowledgementCount) +{ + public bool IsSessionIdle => + BufferedTeleportDestinationCount == 0 + && PendingTeleportStartCount == 0 + && ActiveTeleportCount == 0 + && AcceptedTeleportDestinationCount == 0 + && ActiveRevealCount == 0 + && PendingDestinationReadinessCount == 0 + && HostProjectionCount == 0 + && PendingHostAcknowledgementCount == 0; +} + /// /// Canonical presentation-independent owner of local F751/Position /// correlation and one login/portal reveal generation. The graphical or @@ -21,12 +42,34 @@ namespace AcDream.Runtime.World; public sealed class RuntimeWorldTransitState : IRuntimePortalView { + private sealed class HostProjectionRecord( + RuntimeWorldHostProjectionToken token) + { + public RuntimeWorldHostProjectionToken Token { get; } = token; + public RuntimeWorldHostAcknowledgementStage Pending { get; set; } = + RuntimeWorldHostAcknowledgementStage.ProjectionRegistered; + public bool ProjectionRegistered { get; set; } + public bool SimulationReleaseProjected { get; set; } + public bool DestinationReservationReleased { get; set; } + public bool IsSuperseding { get; set; } + + public RuntimeWorldHostProjectionSnapshot Snapshot => new( + Token, + Pending, + ProjectionRegistered, + SimulationReleaseProjected, + DestinationReservationReleased, + IsSuperseding); + } + public static readonly TimeSpan RetailWaitCueDelay = TimeSpan.FromSeconds(5); private readonly Action _log; private readonly Dictionary _bufferedDestinations = []; + private readonly Dictionary + _hostProjections = []; private RuntimePortalSnapshot _snapshot = RuntimePortalSnapshot.Idle; private long _nextGeneration; private bool _hasLastTeleportStart; @@ -45,6 +88,8 @@ public sealed class RuntimeWorldTransitState } public RuntimePortalSnapshot Snapshot => _snapshot; + public RuntimeWorldTransitOwnershipSnapshot Ownership => + CaptureOwnership(); public bool IsWorldSimulationAvailable => _snapshot.WorldSimulationAvailable; public bool IsTeleportActive => _teleportActive; @@ -57,6 +102,30 @@ public sealed class RuntimeWorldTransitState public int DiagnosticFailureCount { get; private set; } public Exception? LastDiagnosticFailure { get; private set; } + public RuntimeWorldTransitOwnershipSnapshot CaptureOwnership() + { + int pendingHostAcknowledgements = 0; + foreach (HostProjectionRecord host in _hostProjections.Values) + { + pendingHostAcknowledgements = checked( + pendingHostAcknowledgements + + host.Snapshot.PendingAcknowledgementCount); + } + + bool revealActive = _snapshot.Generation != 0 + && !_snapshot.Completed + && !_snapshot.Cancelled; + return new RuntimeWorldTransitOwnershipSnapshot( + _bufferedDestinations.Count, + _hasPendingTeleportStart ? 1 : 0, + _teleportActive ? 1 : 0, + _hasAcceptedDestination ? 1 : 0, + revealActive ? 1 : 0, + revealActive && !_snapshot.IsReady ? 1 : 0, + _hostProjections.Count, + pendingHostAcknowledgements); + } + /// /// Starts initial-login reveal. Portal reveals must instead claim the /// exact F751-correlated destination through @@ -69,15 +138,30 @@ public sealed class RuntimeWorldTransitState /// Atomically claims the one accepted Position paired with the active F751 /// sequence and starts its reveal generation. /// + public bool CanBeginPortalReveal( + ushort teleportSequence, + uint destinationCell) + { + if (!_teleportActive + || teleportSequence != _activeTeleportSequence + || !_hasAcceptedDestination) + { + return false; + } + + RuntimeTeleportDestination destination = _acceptedDestination; + return destination.TeleportSequence == teleportSequence + && destination.CellId != 0u + && destination.CellId == destinationCell; + } + public bool TryBeginPortalReveal( ushort teleportSequence, uint destinationCell, out long generation) { generation = 0; - if (!_teleportActive - || teleportSequence != _activeTeleportSequence - || !_hasAcceptedDestination) + if (!CanBeginPortalReveal(teleportSequence, destinationCell)) { LogRejected( "portal-begin-without-destination", @@ -89,19 +173,6 @@ public sealed class RuntimeWorldTransitState } RuntimeTeleportDestination destination = _acceptedDestination; - if (destination.TeleportSequence != teleportSequence - || destination.CellId == 0u - || destination.CellId != destinationCell) - { - FailInvariant( - "portal-begin-destination-mismatch", - $"sequence={teleportSequence} " - + $"acceptedSequence={destination.TeleportSequence} " - + $"expectedCell=0x{destination.CellId:X8} " - + $"actualCell=0x{destinationCell:X8}"); - return false; - } - generation = BeginRevealCore( RuntimePortalKind.Portal, destinationCell); @@ -110,6 +181,159 @@ public sealed class RuntimeWorldTransitState return true; } + /// + /// Registers one presentation-independent host projection against the + /// exact active reveal. Graphical and direct hosts receive the same token. + /// + public bool TryRegisterHostProjection( + long generation, + uint destinationCell, + out RuntimeWorldHostProjectionToken token) + { + token = new RuntimeWorldHostProjectionToken( + generation, + destinationCell); + if (!token.IsValid + || generation != _snapshot.Generation + || destinationCell != _snapshot.DestinationCell + || _snapshot.Cancelled + || _snapshot.Completed) + { + LogRejected( + "host-register-mismatch", + $"generation={generation} " + + $"cell=0x{destinationCell:X8}"); + token = default; + return false; + } + + if (_hostProjections.TryGetValue( + generation, + out HostProjectionRecord? existing)) + { + if (existing.Token == token) + return true; + + LogRejected( + "host-register-conflict", + $"generation={generation} " + + $"expectedCell=0x{existing.Token.DestinationCell:X8} " + + $"actualCell=0x{destinationCell:X8}"); + token = default; + return false; + } + + _hostProjections.Add( + generation, + new HostProjectionRecord(token)); + return true; + } + + public bool TryGetHostProjection( + in RuntimeWorldHostProjectionToken token, + out RuntimeWorldHostProjectionSnapshot projection) + { + if (token.IsValid + && _hostProjections.TryGetValue( + token.Generation, + out HostProjectionRecord? host) + && host.Token == token) + { + projection = host.Snapshot; + return true; + } + + projection = default; + return false; + } + + /// + /// Marks the graphical reservation-release callback as due before the + /// host executes it. A thrown callback therefore leaves an exact pending + /// acknowledgement instead of losing the unfinished suffix. + /// + public bool RequireDestinationReservationRelease( + in RuntimeWorldHostProjectionToken token) + { + if (!TryGetHostRecord(token, out HostProjectionRecord? host)) + return false; + if (host.DestinationReservationReleased) + return false; + + host.Pending |= RuntimeWorldHostAcknowledgementStage + .DestinationReservationReleased; + return true; + } + + /// + /// Converts an older host projection into an exact withdrawal transaction + /// while a replacement reveal keeps world simulation quiesced. + /// + public bool BeginHostProjectionSupersession( + in RuntimeWorldHostProjectionToken token) + { + if (!TryGetHostRecord(token, out HostProjectionRecord? host)) + return false; + + BeginHostProjectionSupersession(host); + return true; + } + + public bool AcknowledgeHostProjection( + in RuntimeWorldHostAcknowledgement acknowledgement) + { + RuntimeWorldHostAcknowledgementStage stage = + acknowledgement.Stage; + if (!IsSingleStage(stage) + || !TryGetHostRecord( + acknowledgement.Projection, + out HostProjectionRecord? host) + || (host.Pending & stage) == 0) + { + return false; + } + + switch (stage) + { + case RuntimeWorldHostAcknowledgementStage.ProjectionRegistered: + if (host.IsSuperseding) + return false; + host.ProjectionRegistered = true; + break; + case RuntimeWorldHostAcknowledgementStage + .SimulationReleaseProjected: + if (host.Token.Generation == _snapshot.Generation + && !_snapshot.WorldSimulationAvailable) + { + return false; + } + host.SimulationReleaseProjected = true; + break; + case RuntimeWorldHostAcknowledgementStage + .DestinationReservationReleased: + host.DestinationReservationReleased = true; + break; + case RuntimeWorldHostAcknowledgementStage.TerminalProjected: + if (!host.IsSuperseding + && (host.Token.Generation != _snapshot.Generation + || (!_snapshot.Completed + && !_snapshot.Cancelled))) + { + return false; + } + if ((host.Pending & ~stage) != 0) + return false; + host.Pending &= ~stage; + _hostProjections.Remove(host.Token.Generation); + return true; + default: + return false; + } + + host.Pending &= ~stage; + return true; + } + private long BeginRevealCore( RuntimePortalKind kind, uint destinationCell) @@ -125,6 +349,12 @@ public sealed class RuntimeWorldTransitState { Log("superseded", _snapshot); } + if (_hostProjections.TryGetValue( + _snapshot.Generation, + out HostProjectionRecord? superseded)) + { + BeginHostProjectionSupersession(superseded); + } long generation = checked(++_nextGeneration); int failures = _snapshot.InvariantFailureCount; @@ -372,6 +602,10 @@ public sealed class RuntimeWorldTransitState WorldSimulationAvailable = true, PortalMaterializationCount = count, }; + RequireHostStage( + generation, + RuntimeWorldHostAcknowledgementStage + .SimulationReleaseProjected); Log("materialized", _snapshot); return true; } @@ -444,11 +678,16 @@ public sealed class RuntimeWorldTransitState return false; } + bool simulationWasAvailable = + _snapshot.WorldSimulationAvailable; _snapshot = _snapshot with { Completed = true, WorldSimulationAvailable = true, }; + RequireTerminalHostProjection( + generation, + requireSimulationRelease: !simulationWasAvailable); Log("complete", _snapshot); return true; } @@ -463,11 +702,16 @@ public sealed class RuntimeWorldTransitState return false; } + bool simulationWasAvailable = + _snapshot.WorldSimulationAvailable; _snapshot = _snapshot with { Cancelled = true, WorldSimulationAvailable = true, }; + RequireTerminalHostProjection( + generation, + requireSimulationRelease: !simulationWasAvailable); Log("cancel", _snapshot); return true; } @@ -479,6 +723,17 @@ public sealed class RuntimeWorldTransitState /// public void ResetSession() { + if (_hostProjections.Count != 0) + { + RuntimeWorldTransitOwnershipSnapshot ownership = + CaptureOwnership(); + throw new InvalidOperationException( + "World transit cannot reset while a host projection " + + "still owns an acknowledgement suffix " + + $"(hosts={ownership.HostProjectionCount}, " + + $"pending={ownership.PendingHostAcknowledgementCount})."); + } + _snapshot = RuntimePortalSnapshot.Idle; EndTeleport(); _hasLastTeleportStart = false; @@ -486,6 +741,105 @@ public sealed class RuntimeWorldTransitState _bufferedDestinations.Clear(); } + private bool TryGetHostRecord( + in RuntimeWorldHostProjectionToken token, + out HostProjectionRecord host) + { + if (token.IsValid + && _hostProjections.TryGetValue( + token.Generation, + out HostProjectionRecord? candidate) + && candidate.Token == token) + { + host = candidate; + return true; + } + + host = null!; + return false; + } + + private void RequireHostStage( + long generation, + RuntimeWorldHostAcknowledgementStage stage) + { + if (!_hostProjections.TryGetValue( + generation, + out HostProjectionRecord? host)) + { + return; + } + + bool alreadyAcknowledged = stage switch + { + RuntimeWorldHostAcknowledgementStage.ProjectionRegistered => + host.ProjectionRegistered, + RuntimeWorldHostAcknowledgementStage + .SimulationReleaseProjected => + host.SimulationReleaseProjected, + RuntimeWorldHostAcknowledgementStage + .DestinationReservationReleased => + host.DestinationReservationReleased, + _ => false, + }; + if (!alreadyAcknowledged) + host.Pending |= stage; + } + + private void RequireTerminalHostProjection( + long generation, + bool requireSimulationRelease) + { + if (!_hostProjections.TryGetValue( + generation, + out HostProjectionRecord? host)) + { + return; + } + + // A host that is terminating no longer has to finish acquiring a + // projection. It must instead release every resource it did acquire. + host.Pending &= + ~RuntimeWorldHostAcknowledgementStage.ProjectionRegistered; + if (requireSimulationRelease + && !host.SimulationReleaseProjected) + { + host.Pending |= RuntimeWorldHostAcknowledgementStage + .SimulationReleaseProjected; + } + if (!host.DestinationReservationReleased) + { + host.Pending |= RuntimeWorldHostAcknowledgementStage + .DestinationReservationReleased; + } + host.Pending |= + RuntimeWorldHostAcknowledgementStage.TerminalProjected; + } + + private static void BeginHostProjectionSupersession( + HostProjectionRecord host) + { + host.IsSuperseding = true; + host.Pending &= + ~(RuntimeWorldHostAcknowledgementStage.ProjectionRegistered + | RuntimeWorldHostAcknowledgementStage + .SimulationReleaseProjected); + if (!host.DestinationReservationReleased) + { + host.Pending |= RuntimeWorldHostAcknowledgementStage + .DestinationReservationReleased; + } + host.Pending |= + RuntimeWorldHostAcknowledgementStage.TerminalProjected; + } + + private static bool IsSingleStage( + RuntimeWorldHostAcknowledgementStage stage) + { + int value = (int)stage; + return value != 0 && (value & (value - 1)) == 0; + } + private bool IsCurrentPortalDestination( long generation, ushort teleportSequence, diff --git a/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs b/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs index b816d50a..43be61a7 100644 --- a/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs +++ b/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs @@ -6,6 +6,7 @@ using AcDream.App.Rendering.Scene; using AcDream.App.Streaming; using AcDream.App.UI.Testing; using AcDream.Runtime; +using AcDream.Runtime.World; using SixLabors.ImageSharp; namespace AcDream.App.Tests.Diagnostics; @@ -179,6 +180,19 @@ public sealed class WorldLifecycleAutomationControllerTests Path.Combine(directory, "screenshots")); var controller = new WorldLifecycleAutomationController( () => reveal, + () => new RuntimeWorldEnvironmentOwnershipSnapshot( + IsInitialized: true, + DayGroupDefinitionCount: 4, + ActiveDayGroupCount: 1), + () => new RuntimeWorldTransitOwnershipSnapshot( + BufferedTeleportDestinationCount: 0, + PendingTeleportStartCount: 0, + ActiveTeleportCount: 0, + AcceptedTeleportDestinationCount: 0, + ActiveRevealCount: 0, + PendingDestinationReadinessCount: 0, + HostProjectionCount: 0, + PendingHostAcknowledgementCount: 0), () => 3, _ => resources, screenshots, @@ -205,6 +219,15 @@ public sealed class WorldLifecycleAutomationControllerTests using JsonDocument json = JsonDocument.Parse(line); Assert.Equal("dungeon", json.RootElement.GetProperty("name").GetString()); Assert.Equal(7, json.RootElement.GetProperty("reveal").GetProperty("generation").GetInt64()); + Assert.Equal( + 4, + json.RootElement.GetProperty("environmentOwnership") + .GetProperty("dayGroupDefinitionCount").GetInt32()); + Assert.Equal( + 0, + json.RootElement.GetProperty("transitOwnership") + .GetProperty("pendingHostAcknowledgementCount") + .GetInt32()); Assert.Equal(4, json.RootElement.GetProperty("render").GetProperty("world") .GetProperty("visibleLandblocks").GetInt32()); Assert.True(json.RootElement.GetProperty("render").GetProperty("presentation") @@ -373,6 +396,8 @@ public sealed class WorldLifecycleAutomationControllerTests string directory, Func capture) => new( + () => default, + () => default, () => default, () => 0, capture, diff --git a/tests/AcDream.App.Tests/Runtime/RuntimeWorldTransitOwnershipTests.cs b/tests/AcDream.App.Tests/Runtime/RuntimeWorldTransitOwnershipTests.cs index 997a3f73..b88b1b4c 100644 --- a/tests/AcDream.App.Tests/Runtime/RuntimeWorldTransitOwnershipTests.cs +++ b/tests/AcDream.App.Tests/Runtime/RuntimeWorldTransitOwnershipTests.cs @@ -81,7 +81,34 @@ public sealed class RuntimeWorldTransitOwnershipTests coordinatorFields, field => field.Name is "_activeGeneration" or "_worldSimulationReleased" - or "_lifecycle"); + or "_lifecycle" + or "_reservationGeneration" + or "_worldViewportReleased" + or "_hostProjection"); + Assert.DoesNotContain( + coordinatorFields, + field => field.FieldType == typeof(RuntimePortalSnapshot) + || field.FieldType == typeof(RuntimeDestinationReadiness) + || field.FieldType == typeof(long) + || field.FieldType == typeof(uint)); + + Type hostProjection = Assert.Single( + typeof(WorldRevealCoordinator).GetNestedTypes( + System.Reflection.BindingFlags.NonPublic), + type => type.Name == "HostProjection"); + var hostFields = hostProjection.GetFields( + System.Reflection.BindingFlags.Instance + | System.Reflection.BindingFlags.NonPublic); + Assert.Single( + hostFields, + field => field.FieldType + == typeof(RuntimeWorldHostProjectionToken)); + Assert.DoesNotContain( + hostFields, + field => field.FieldType == typeof(RuntimePortalSnapshot) + || field.FieldType == typeof(RuntimeDestinationReadiness) + || field.FieldType == typeof(long) + || field.FieldType == typeof(uint)); var teleportFields = typeof(LocalPlayerTeleportController) .GetFields( diff --git a/tests/AcDream.App.Tests/Streaming/WorldGenerationQuiescenceTests.cs b/tests/AcDream.App.Tests/Streaming/WorldGenerationQuiescenceTests.cs index 69f4bb0d..040d9e61 100644 --- a/tests/AcDream.App.Tests/Streaming/WorldGenerationQuiescenceTests.cs +++ b/tests/AcDream.App.Tests/Streaming/WorldGenerationQuiescenceTests.cs @@ -144,6 +144,39 @@ public sealed class WorldGenerationQuiescenceTests Assert.Equal(inventoryGuid, selection.SelectedObjectId); } + [Fact] + public void ReentrantBeginDuringAudioResume_ReconcilesToNewestEdge() + { + var transit = new RuntimeWorldTransitState(); + var world = new GpuWorldState( + availability: new WorldGenerationAvailabilityState(transit)); + var audio = new ReentrantAudioQuiescence(); + var quiescence = new WorldGenerationQuiescence( + new SelectionState(), + world, + _ => null, + audio); + var edge = new WorldGenerationQuiescenceEdge( + ShouldApply: true, + ClearWorldSelection: false); + quiescence.CommitBegin(edge); + audio.OnResume = () => + { + audio.OnResume = null; + quiescence.CommitBegin(edge); + }; + + quiescence.ObserveReleased(); + + Assert.Equal(2, audio.SuspendCalls); + Assert.Equal(1, audio.ResumeCalls); + + quiescence.ObserveReleased(); + + Assert.Equal(2, audio.SuspendCalls); + Assert.Equal(2, audio.ResumeCalls); + } + private static RuntimeDestinationReadiness Ready( long generation, uint destinationCell) => @@ -178,4 +211,19 @@ public sealed class WorldGenerationQuiescenceTests public void SuspendWorldAudio() => SuspendCalls++; public void ResumeWorldAudio() => ResumeCalls++; } + + private sealed class ReentrantAudioQuiescence : IWorldAudioQuiescence + { + public int SuspendCalls { get; private set; } + public int ResumeCalls { get; private set; } + public Action? OnResume { get; set; } + + public void SuspendWorldAudio() => SuspendCalls++; + + public void ResumeWorldAudio() + { + ResumeCalls++; + OnResume?.Invoke(); + } + } } diff --git a/tests/AcDream.App.Tests/Streaming/WorldRevealCoordinatorTests.cs b/tests/AcDream.App.Tests/Streaming/WorldRevealCoordinatorTests.cs index 24a4d74f..1235d12e 100644 --- a/tests/AcDream.App.Tests/Streaming/WorldRevealCoordinatorTests.cs +++ b/tests/AcDream.App.Tests/Streaming/WorldRevealCoordinatorTests.cs @@ -409,8 +409,9 @@ public sealed class WorldRevealCoordinatorTests (second, 0x20210123u, 0), ], scheduler.Begins); - coordinator.Complete(); - Assert.Equal([second], scheduler.Ends); + Assert.Equal([first], scheduler.Ends); + coordinator.Cancel(); + Assert.Equal([first, second], scheduler.Ends); } [Fact] @@ -434,10 +435,200 @@ public sealed class WorldRevealCoordinatorTests sequence: 2); Assert.Equal([first, second], resources.Begins); + Assert.Equal([first], resources.Ends); coordinator.RevealWorldViewport(); coordinator.Complete(); - Assert.Equal([second], resources.Ends); + Assert.Equal([first, second], resources.Ends); + } + + [Fact] + public void RegistrationCallback_ReentrantReplacementRetiresExactOldSuffix() + { + var state = new State(); + var scheduler = new RecordingDestinationScheduler(); + WorldRevealCoordinator coordinator = null!; + long replacement = 0; + scheduler.OnBegin = () => + { + scheduler.OnBegin = null; + replacement = coordinator.BeginLogin(0x20210123u); + }; + coordinator = state.Build(streaming: scheduler); + + long first = coordinator.BeginLogin(0x11340021u); + + Assert.NotEqual(first, replacement); + Assert.Equal(replacement, coordinator.Snapshot.Generation); + Assert.Equal( + [ + (first, 0x11340021u, 1), + (replacement, 0x20210123u, 0), + ], + scheduler.Begins); + Assert.Equal([first], scheduler.Ends); + Assert.Equal(1, coordinator.Ownership.HostProjectionCount); + Assert.Equal( + 0, + coordinator.Ownership.PendingHostAcknowledgementCount); + + coordinator.Cancel(); + + Assert.Equal([first, replacement], scheduler.Ends); + Assert.Equal(0, coordinator.Ownership.HostProjectionCount); + } + + [Fact] + public void RegistrationFailure_RetriesExactSuffixWithoutReplayingBegin() + { + var state = new State(); + var scheduler = new RecordingDestinationScheduler(); + var resources = new RecordingRenderResourceScheduler + { + ThrowBeginCount = 1, + }; + WorldRevealCoordinator coordinator = state.Build( + streaming: scheduler, + renderResources: resources); + + Assert.Throws( + () => coordinator.BeginLogin(0x11340021u)); + Assert.Single(scheduler.Begins); + Assert.Equal(1, resources.BeginAttempts); + Assert.Empty(resources.Begins); + Assert.Equal(1, coordinator.Ownership.HostProjectionCount); + Assert.Equal( + 1, + coordinator.Ownership.PendingHostAcknowledgementCount); + + coordinator.RetryPendingHostWork(); + + Assert.Single(scheduler.Begins); + Assert.Equal(2, resources.BeginAttempts); + Assert.Single(resources.Begins); + Assert.Equal( + 0, + coordinator.Ownership.PendingHostAcknowledgementCount); + + coordinator.Cancel(); + Assert.Equal(0, coordinator.Ownership.HostProjectionCount); + } + + [Fact] + public void ReservationReleaseFailure_RetriesOnlyUnfinishedCallback() + { + var state = new State(); + var scheduler = new RecordingDestinationScheduler(); + var resources = new RecordingRenderResourceScheduler + { + ThrowEndCount = 1, + }; + WorldRevealCoordinator coordinator = state.Build( + streaming: scheduler, + renderResources: resources); + long generation = coordinator.BeginLogin(0x11340021u); + + Assert.Throws( + coordinator.RevealWorldViewport); + Assert.Equal([generation], scheduler.Ends); + Assert.Equal(1, resources.EndAttempts); + Assert.Empty(resources.Ends); + Assert.Equal( + 1, + coordinator.Ownership.PendingHostAcknowledgementCount); + + coordinator.RetryPendingHostWork(); + + Assert.Equal([generation], scheduler.Ends); + Assert.Equal(2, resources.EndAttempts); + Assert.Equal([generation], resources.Ends); + Assert.Equal( + 0, + coordinator.Ownership.PendingHostAcknowledgementCount); + coordinator.Cancel(); + } + + [Fact] + public void ResetFailure_RetainsWithdrawalAndConvergesOnRetry() + { + var state = new State(); + var scheduler = new RecordingDestinationScheduler + { + ThrowEndCount = 1, + }; + WorldRevealCoordinator coordinator = + state.Build(streaming: scheduler); + coordinator.BeginLogin(0x11340021u); + + Assert.Throws(coordinator.ResetSession); + Assert.True(coordinator.Snapshot.Cancelled); + Assert.Equal(1, coordinator.Ownership.HostProjectionCount); + Assert.True( + coordinator.Ownership.PendingHostAcknowledgementCount > 0); + + coordinator.ResetSession(); + + Assert.Equal(RuntimePortalSnapshot.Idle, coordinator.Snapshot); + Assert.True(coordinator.Ownership.IsSessionIdle); + Assert.Equal(2, scheduler.EndAttempts); + Assert.Single(scheduler.Ends); + } + + [Fact] + public void SimulationReleaseFailure_RetriesAudioWithoutReplayingMaterialization() + { + var transit = new RuntimeWorldTransitState(); + var availability = new WorldGenerationAvailabilityState(transit); + var world = new GpuWorldState(availability: availability); + var audio = new RecordingAudioQuiescence + { + ThrowResumeCount = 1, + }; + var quiescence = new WorldGenerationQuiescence( + new SelectionState(), + world, + _ => null, + audio); + var coordinator = new WorldRevealCoordinator( + transit, + isRenderNeighborhoodReady: (_, _) => true, + isSpawnCellReady: _ => true, + isTerrainNeighborhoodReady: (_, _) => true, + areCompositeTexturesReady: () => true, + prepareCompositeTextures: (_, _) => { }, + invalidateCompositeTextures: () => { }, + isSpawnClaimUnhydratable: _ => false, + quiescence: quiescence); + const uint cell = 0x3032001Cu; + long generation = BeginPortal( + coordinator, + transit, + cell, + sequence: 1); + Assert.True(coordinator.Evaluate(cell).IsReady); + + Assert.Throws(() => + coordinator.ObserveMaterialized( + generation, + 1, + cell)); + Assert.True(coordinator.Snapshot.Materialized); + Assert.Equal(1, coordinator.PortalMaterializationCount); + Assert.Equal(1, audio.ResumeAttempts); + Assert.Equal(0, audio.ResumeCalls); + Assert.Equal( + 1, + coordinator.Ownership.PendingHostAcknowledgementCount); + + coordinator.RetryPendingHostWork(); + + Assert.Equal(2, audio.ResumeAttempts); + Assert.Equal(1, audio.ResumeCalls); + Assert.Equal(1, coordinator.PortalMaterializationCount); + Assert.Equal( + 0, + coordinator.Ownership.PendingHostAcknowledgementCount); + coordinator.Cancel(); } private static long BeginPortal( @@ -471,9 +662,32 @@ public sealed class WorldRevealCoordinatorTests { public int SuspendCalls { get; private set; } public int ResumeCalls { get; private set; } + public int SuspendAttempts { get; private set; } + public int ResumeAttempts { get; private set; } + public int ThrowSuspendCount { get; set; } + public int ThrowResumeCount { get; set; } - public void SuspendWorldAudio() => SuspendCalls++; - public void ResumeWorldAudio() => ResumeCalls++; + public void SuspendWorldAudio() + { + SuspendAttempts++; + if (ThrowSuspendCount > 0) + { + ThrowSuspendCount--; + throw new InvalidOperationException("suspend failed"); + } + SuspendCalls++; + } + + public void ResumeWorldAudio() + { + ResumeAttempts++; + if (ThrowResumeCount > 0) + { + ThrowResumeCount--; + throw new InvalidOperationException("resume failed"); + } + ResumeCalls++; + } } private sealed class RecordingDestinationScheduler @@ -481,16 +695,40 @@ public sealed class WorldRevealCoordinatorTests { public List<(long Generation, uint Cell, int Radius)> Begins { get; } = []; public List Ends { get; } = []; + public int BeginAttempts { get; private set; } + public int EndAttempts { get; private set; } + public int ThrowBeginCount { get; set; } + public int ThrowEndCount { get; set; } + public Action? OnBegin { get; set; } + public Action? OnEnd { get; set; } public void BeginDestinationReservation( long revealGeneration, uint destinationCell, - int requiredRenderRadius) => + int requiredRenderRadius) + { + BeginAttempts++; + if (ThrowBeginCount > 0) + { + ThrowBeginCount--; + throw new InvalidOperationException("stream begin failed"); + } Begins.Add( (revealGeneration, destinationCell, requiredRenderRadius)); + OnBegin?.Invoke(); + } - public void EndDestinationReservation(long revealGeneration) => + public void EndDestinationReservation(long revealGeneration) + { + EndAttempts++; + if (ThrowEndCount > 0) + { + ThrowEndCount--; + throw new InvalidOperationException("stream end failed"); + } Ends.Add(revealGeneration); + OnEnd?.Invoke(); + } } private sealed class RecordingRenderResourceScheduler @@ -498,11 +736,31 @@ public sealed class WorldRevealCoordinatorTests { public List Begins { get; } = []; public List Ends { get; } = []; + public int BeginAttempts { get; private set; } + public int EndAttempts { get; private set; } + public int ThrowBeginCount { get; set; } + public int ThrowEndCount { get; set; } - public void BeginDestinationReveal(long revealGeneration) => + public void BeginDestinationReveal(long revealGeneration) + { + BeginAttempts++; + if (ThrowBeginCount > 0) + { + ThrowBeginCount--; + throw new InvalidOperationException("render begin failed"); + } Begins.Add(revealGeneration); + } - public void EndDestinationReveal(long revealGeneration) => + public void EndDestinationReveal(long revealGeneration) + { + EndAttempts++; + if (ThrowEndCount > 0) + { + ThrowEndCount--; + throw new InvalidOperationException("render end failed"); + } Ends.Add(revealGeneration); + } } } diff --git a/tests/AcDream.Runtime.Tests/GameRuntimeContractTests.cs b/tests/AcDream.Runtime.Tests/GameRuntimeContractTests.cs index 844de703..7dfa551b 100644 --- a/tests/AcDream.Runtime.Tests/GameRuntimeContractTests.cs +++ b/tests/AcDream.Runtime.Tests/GameRuntimeContractTests.cs @@ -1,5 +1,6 @@ using System.Reflection; using AcDream.Runtime.Gameplay; +using AcDream.Runtime.World; namespace AcDream.Runtime.Tests; @@ -183,7 +184,20 @@ public sealed class GameRuntimeContractTests InteractionTransactions: default), default, default, - default); + new RuntimeWorldEnvironmentOwnershipSnapshot( + IsInitialized: true, + DayGroupDefinitionCount: 4, + ActiveDayGroupCount: 1), + default, + new RuntimeWorldTransitOwnershipSnapshot( + BufferedTeleportDestinationCount: 0, + PendingTeleportStartCount: 0, + ActiveTeleportCount: 1, + AcceptedTeleportDestinationCount: 0, + ActiveRevealCount: 1, + PendingDestinationReadinessCount: 1, + HostProjectionCount: 1, + PendingHostAcknowledgementCount: 2)); recorder.AddCheckpoint(stamp, checkpoint); @@ -191,6 +205,8 @@ public sealed class GameRuntimeContractTests Assert.Contains("inventory-state=2:4:1:3", entry.Text); Assert.Contains("character=5:6:7:8:0:0", entry.Text); Assert.Contains("social=9:10:11", entry.Text); + Assert.Contains("environment-owner=True:4:1", entry.Text); + Assert.Contains("transit-owner=0:0:1:0:1:1:1:2", entry.Text); Assert.Contains( "actions=14:50000001:15:4:1:16:1:00000000:0:00000000:" + "00000000:00000000:00000000:0:0:0:0:00000000:00000000:" + diff --git a/tests/AcDream.Runtime.Tests/World/RuntimeWorldTransitDirectHostTests.cs b/tests/AcDream.Runtime.Tests/World/RuntimeWorldTransitDirectHostTests.cs new file mode 100644 index 00000000..1c86170a --- /dev/null +++ b/tests/AcDream.Runtime.Tests/World/RuntimeWorldTransitDirectHostTests.cs @@ -0,0 +1,204 @@ +using System.Numerics; +using AcDream.Core.Physics; +using AcDream.Runtime.World; + +namespace AcDream.Runtime.Tests.World; + +public sealed class RuntimeWorldTransitDirectHostTests +{ + private static readonly string[] ForbiddenHostAssemblyPrefixes = + [ + "AcDream.App", + "AcDream.UI.", + "Silk.NET", + "OpenAL", + "Arch", + "ImGui", + ]; + + private const uint OutdoorCell = 0x11340021u; + private const uint IndoorCell = 0x8A020164u; + + [Fact] + public void DirectHost_CompletesLoginPortalCancellationAndReset() + { + string[] loadedAssemblies = AppDomain.CurrentDomain.GetAssemblies() + .Select(static assembly => + assembly.GetName().Name ?? string.Empty) + .ToArray(); + Assert.DoesNotContain( + loadedAssemblies, + name => ForbiddenHostAssemblyPrefixes.Any( + prefix => name.StartsWith( + prefix, + StringComparison.OrdinalIgnoreCase))); + + var state = new RuntimeWorldTransitState(); + var host = new DirectHost(state); + + host.CompleteLogin(OutdoorCell); + Assert.True(state.Snapshot.Completed); + Assert.True(state.Snapshot.WorldViewportObserved); + Assert.Equal(0, state.Ownership.HostProjectionCount); + + state.ResetSession(); + host.CompletePortal(IndoorCell, sequence: 1); + Assert.True(state.Snapshot.Completed); + Assert.True(state.Snapshot.Materialized); + Assert.Equal(1, state.Snapshot.PortalMaterializationCount); + Assert.Equal(0, state.Ownership.PendingHostAcknowledgementCount); + + state.EndTeleport(); + state.ResetSession(); + host.CancelLogin(OutdoorCell); + Assert.True(state.Snapshot.Cancelled); + Assert.Equal(0, state.Ownership.HostProjectionCount); + + state.ResetSession(); + Assert.Equal(RuntimePortalSnapshot.Idle, state.Snapshot); + Assert.True(state.Ownership.IsSessionIdle); + } + + private sealed class DirectHost(RuntimeWorldTransitState state) + { + public void CompleteLogin(uint cell) + { + long generation = state.BeginLoginReveal(cell); + RuntimeWorldHostProjectionToken host = + Register(generation, cell); + Acknowledge( + host, + RuntimeWorldHostAcknowledgementStage.ProjectionRegistered); + Assert.True(state.AcknowledgeDestinationReadiness( + Ready(generation, cell))); + Assert.True(state.Complete(generation)); + DrainPending(host); + Assert.True(state.AcknowledgeWorldViewportVisible(generation)); + } + + public void CompletePortal(uint cell, ushort sequence) + { + Assert.True(state.TryQueueTeleportStart(sequence)); + Assert.True(state.ActivateQueuedTeleport()); + Assert.True(state.OfferTeleportDestination( + Destination(cell, sequence), + teleportTimestampAdvanced: true)); + Assert.True(state.TryBeginPortalReveal( + sequence, + cell, + out long generation)); + RuntimeWorldHostProjectionToken host = + Register(generation, cell); + Acknowledge( + host, + RuntimeWorldHostAcknowledgementStage.ProjectionRegistered); + Assert.True(state.AcknowledgeDestinationReadiness( + Ready(generation, cell))); + Assert.True(state.AcknowledgePortalMaterialized( + generation, + sequence, + cell)); + DrainPending(host); + Assert.True(state.RequireDestinationReservationRelease(host)); + DrainPending(host); + Assert.True(state.AcknowledgeWorldViewportVisible(generation)); + Assert.True(state.Complete(generation)); + DrainPending(host); + } + + public void CancelLogin(uint cell) + { + long generation = state.BeginLoginReveal(cell); + RuntimeWorldHostProjectionToken host = + Register(generation, cell); + Acknowledge( + host, + RuntimeWorldHostAcknowledgementStage.ProjectionRegistered); + Assert.True(state.Cancel(generation)); + DrainPending(host); + } + + private RuntimeWorldHostProjectionToken Register( + long generation, + uint cell) + { + Assert.True(state.TryRegisterHostProjection( + generation, + cell, + out RuntimeWorldHostProjectionToken host)); + return host; + } + + private void DrainPending(RuntimeWorldHostProjectionToken host) + { + while (state.TryGetHostProjection( + host, + out RuntimeWorldHostProjectionSnapshot projection)) + { + RuntimeWorldHostAcknowledgementStage pending = + projection.PendingAcknowledgements; + if ((pending & RuntimeWorldHostAcknowledgementStage + .SimulationReleaseProjected) != 0) + { + Acknowledge( + host, + RuntimeWorldHostAcknowledgementStage + .SimulationReleaseProjected); + continue; + } + if ((pending & RuntimeWorldHostAcknowledgementStage + .DestinationReservationReleased) != 0) + { + Acknowledge( + host, + RuntimeWorldHostAcknowledgementStage + .DestinationReservationReleased); + continue; + } + if ((pending & RuntimeWorldHostAcknowledgementStage + .TerminalProjected) != 0) + { + Acknowledge( + host, + RuntimeWorldHostAcknowledgementStage + .TerminalProjected); + continue; + } + break; + } + } + + private void Acknowledge( + RuntimeWorldHostProjectionToken host, + RuntimeWorldHostAcknowledgementStage stage) => + Assert.True(state.AcknowledgeHostProjection( + new RuntimeWorldHostAcknowledgement(host, stage))); + } + + private static RuntimeDestinationReadiness Ready( + long generation, + uint cell) => + new( + generation, + cell, + IsIndoor: (cell & 0xFFFFu) >= 0x0100u, + IsUnhydratable: false, + RequiredRenderRadius: (cell & 0xFFFFu) >= 0x0100u ? 0 : 1, + IsRenderNeighborhoodReady: true, + AreCompositeTexturesReady: true, + IsCollisionReady: true); + + private static RuntimeTeleportDestination Destination( + uint cell, + ushort sequence) => + new( + EntityGuid: 0x50000001u, + InstanceSequence: 1, + PositionSequence: 1, + TeleportSequence: sequence, + ForcePositionSequence: 1, + Position: new Position( + cell, + Vector3.Zero, + Quaternion.Identity)); +} diff --git a/tests/AcDream.Runtime.Tests/World/RuntimeWorldTransitStateTests.cs b/tests/AcDream.Runtime.Tests/World/RuntimeWorldTransitStateTests.cs index a132a00b..b4c5d0ac 100644 --- a/tests/AcDream.Runtime.Tests/World/RuntimeWorldTransitStateTests.cs +++ b/tests/AcDream.Runtime.Tests/World/RuntimeWorldTransitStateTests.cs @@ -338,6 +338,18 @@ public sealed class RuntimeWorldTransitStateTests BeginPortal(first, OutdoorCell); long secondGeneration = second.BeginLoginReveal(OtherCell); + RuntimeWorldHostProjectionToken firstHost = + RegisterHost(first, firstGeneration, OutdoorCell); + RuntimeWorldHostProjectionToken secondHost = + RegisterHost(second, secondGeneration, OtherCell); + Assert.True(Acknowledge( + first, + firstHost, + RuntimeWorldHostAcknowledgementStage.ProjectionRegistered)); + Assert.True(Acknowledge( + second, + secondHost, + RuntimeWorldHostAcknowledgementStage.ProjectionRegistered)); first.AcknowledgeDestinationReadiness( Ready(firstGeneration, OutdoorCell)); first.AcknowledgePortalMaterialized( @@ -351,6 +363,13 @@ public sealed class RuntimeWorldTransitStateTests Assert.False(second.IsWorldSimulationAvailable); Assert.Equal(OtherCell, second.Snapshot.DestinationCell); Assert.Equal(1, secondGeneration); + Assert.False(Acknowledge( + second, + firstHost, + RuntimeWorldHostAcknowledgementStage + .SimulationReleaseProjected)); + Assert.Equal(1, first.Ownership.PendingHostAcknowledgementCount); + Assert.Equal(0, second.Ownership.PendingHostAcknowledgementCount); } [Fact] @@ -616,6 +635,195 @@ public sealed class RuntimeWorldTransitStateTests Assert.True(state.TryQueueTeleportStart(8)); } + [Fact] + public void HostProjection_TracksExactGenerationScopedAcknowledgementSuffix() + { + var state = new RuntimeWorldTransitState(); + long generation = BeginPortal(state, OutdoorCell); + RuntimeWorldHostProjectionToken host = + RegisterHost(state, generation, OutdoorCell); + + Assert.Equal( + new RuntimeWorldTransitOwnershipSnapshot( + BufferedTeleportDestinationCount: 0, + PendingTeleportStartCount: 0, + ActiveTeleportCount: 1, + AcceptedTeleportDestinationCount: 0, + ActiveRevealCount: 1, + PendingDestinationReadinessCount: 1, + HostProjectionCount: 1, + PendingHostAcknowledgementCount: 1), + state.Ownership); + Assert.False(Acknowledge( + state, + host with { Generation = generation + 1 }, + RuntimeWorldHostAcknowledgementStage.ProjectionRegistered)); + Assert.False(Acknowledge( + state, + host with { DestinationCell = OtherCell }, + RuntimeWorldHostAcknowledgementStage.ProjectionRegistered)); + Assert.False(Acknowledge( + state, + host, + RuntimeWorldHostAcknowledgementStage.None)); + Assert.False(Acknowledge( + state, + host, + RuntimeWorldHostAcknowledgementStage.ProjectionRegistered + | RuntimeWorldHostAcknowledgementStage + .DestinationReservationReleased)); + Assert.True(Acknowledge( + state, + host, + RuntimeWorldHostAcknowledgementStage.ProjectionRegistered)); + + Assert.True(state.AcknowledgeDestinationReadiness( + Ready(generation, OutdoorCell))); + Assert.True(state.AcknowledgePortalMaterialized( + generation, + 1, + OutdoorCell)); + Assert.Equal(1, state.Ownership.PendingHostAcknowledgementCount); + Assert.True(Acknowledge( + state, + host, + RuntimeWorldHostAcknowledgementStage + .SimulationReleaseProjected)); + + Assert.True(state.RequireDestinationReservationRelease(host)); + Assert.True(Acknowledge( + state, + host, + RuntimeWorldHostAcknowledgementStage + .DestinationReservationReleased)); + Assert.True(state.AcknowledgeWorldViewportVisible(generation)); + Assert.True(state.Complete(generation)); + Assert.Equal(1, state.Ownership.PendingHostAcknowledgementCount); + Assert.True(Acknowledge( + state, + host, + RuntimeWorldHostAcknowledgementStage.TerminalProjected)); + Assert.Equal(0, state.Ownership.HostProjectionCount); + Assert.Equal(0, state.Ownership.PendingHostAcknowledgementCount); + + state.EndTeleport(); + state.ResetSession(); + Assert.True(state.Ownership.IsSessionIdle); + } + + [Fact] + public void Cancel_ReplacesIncompleteRegistrationWithRetryableWithdrawal() + { + var state = new RuntimeWorldTransitState(); + long generation = state.BeginLoginReveal(OutdoorCell); + RuntimeWorldHostProjectionToken host = + RegisterHost(state, generation, OutdoorCell); + + Assert.True(state.Cancel(generation)); + Assert.True(state.TryGetHostProjection( + host, + out RuntimeWorldHostProjectionSnapshot projection)); + Assert.Equal( + RuntimeWorldHostAcknowledgementStage + .SimulationReleaseProjected + | RuntimeWorldHostAcknowledgementStage + .DestinationReservationReleased + | RuntimeWorldHostAcknowledgementStage.TerminalProjected, + projection.PendingAcknowledgements); + Assert.False(Acknowledge( + state, + host, + RuntimeWorldHostAcknowledgementStage.TerminalProjected)); + Assert.True(Acknowledge( + state, + host, + RuntimeWorldHostAcknowledgementStage + .SimulationReleaseProjected)); + Assert.True(Acknowledge( + state, + host, + RuntimeWorldHostAcknowledgementStage + .DestinationReservationReleased)); + Assert.True(Acknowledge( + state, + host, + RuntimeWorldHostAcknowledgementStage.TerminalProjected)); + + state.ResetSession(); + Assert.True(state.Ownership.IsSessionIdle); + } + + [Fact] + public void Supersession_PreservesOldHostWithdrawalByExactGeneration() + { + var state = new RuntimeWorldTransitState(); + long first = state.BeginLoginReveal(OutdoorCell); + RuntimeWorldHostProjectionToken firstHost = + RegisterHost(state, first, OutdoorCell); + Assert.True(Acknowledge( + state, + firstHost, + RuntimeWorldHostAcknowledgementStage.ProjectionRegistered)); + + long second = BeginPortal(state, OtherCell, sequence: 2); + + Assert.True(state.TryGetHostProjection( + firstHost, + out RuntimeWorldHostProjectionSnapshot superseded)); + Assert.True(superseded.IsSuperseding); + Assert.Equal( + RuntimeWorldHostAcknowledgementStage + .DestinationReservationReleased + | RuntimeWorldHostAcknowledgementStage.TerminalProjected, + superseded.PendingAcknowledgements); + Assert.False(Acknowledge( + state, + firstHost, + RuntimeWorldHostAcknowledgementStage.TerminalProjected)); + Assert.True(Acknowledge( + state, + firstHost, + RuntimeWorldHostAcknowledgementStage + .DestinationReservationReleased)); + Assert.True(Acknowledge( + state, + firstHost, + RuntimeWorldHostAcknowledgementStage.TerminalProjected)); + Assert.Equal(second, state.Snapshot.Generation); + Assert.Equal(0, state.Ownership.HostProjectionCount); + } + + [Fact] + public void ResetSession_RejectsUnacknowledgedHostProjection() + { + var state = new RuntimeWorldTransitState(); + long generation = state.BeginLoginReveal(OutdoorCell); + RuntimeWorldHostProjectionToken host = + RegisterHost(state, generation, OutdoorCell); + + InvalidOperationException error = + Assert.Throws(state.ResetSession); + Assert.Contains("hosts=1", error.Message); + Assert.Equal(generation, state.Snapshot.Generation); + + Assert.True(state.Cancel(generation)); + Assert.True(Acknowledge( + state, + host, + RuntimeWorldHostAcknowledgementStage + .SimulationReleaseProjected)); + Assert.True(Acknowledge( + state, + host, + RuntimeWorldHostAcknowledgementStage + .DestinationReservationReleased)); + Assert.True(Acknowledge( + state, + host, + RuntimeWorldHostAcknowledgementStage.TerminalProjected)); + state.ResetSession(); + } + private static long BeginPortal( RuntimeWorldTransitState state, uint cell, @@ -633,6 +841,25 @@ public sealed class RuntimeWorldTransitStateTests return generation; } + private static RuntimeWorldHostProjectionToken RegisterHost( + RuntimeWorldTransitState state, + long generation, + uint cell) + { + Assert.True(state.TryRegisterHostProjection( + generation, + cell, + out RuntimeWorldHostProjectionToken host)); + return host; + } + + private static bool Acknowledge( + RuntimeWorldTransitState state, + RuntimeWorldHostProjectionToken host, + RuntimeWorldHostAcknowledgementStage stage) => + state.AcknowledgeHostProjection( + new RuntimeWorldHostAcknowledgement(host, stage)); + private static RuntimeTeleportDestination Destination( uint cell, ushort sequence, diff --git a/tools/run-connected-world-lifecycle-gate.ps1 b/tools/run-connected-world-lifecycle-gate.ps1 index c0d4735b..d61f55bc 100644 --- a/tools/run-connected-world-lifecycle-gate.ps1 +++ b/tools/run-connected-world-lifecycle-gate.ps1 @@ -140,6 +140,8 @@ function Read-Checkpoints([string]$Path) { function Validate-Checkpoint([string]$SessionLabel, [object]$Checkpoint) { $name = $Checkpoint.name $reveal = $Checkpoint.reveal + $environmentOwnership = $Checkpoint.environmentOwnership + $transitOwnership = $Checkpoint.transitOwnership $resources = $Checkpoint.resources if (-not $reveal.readiness.isReady) { $failures.Add("${SessionLabel}/${name}: reveal was not ready") @@ -161,6 +163,31 @@ function Validate-Checkpoint([string]$SessionLabel, [object]$Checkpoint) { $failures.Add("${SessionLabel}/${name}: collision was not ready") } } + if (-not $environmentOwnership.isInitialized) { + $failures.Add("${SessionLabel}/${name}: Runtime world environment is not initialized") + } + if (($environmentOwnership.dayGroupDefinitionCount -le 0) -or + ($environmentOwnership.activeDayGroupCount -ne 1)) { + $failures.Add(( + "${SessionLabel}/${name}: Runtime environment ownership is {0}/{1}, expected definitions with one active group" -f + $environmentOwnership.dayGroupDefinitionCount, + $environmentOwnership.activeDayGroupCount)) + } + foreach ($field in @( + 'bufferedTeleportDestinationCount', + 'pendingTeleportStartCount', + 'activeTeleportCount', + 'acceptedTeleportDestinationCount', + 'activeRevealCount', + 'pendingDestinationReadinessCount', + 'hostProjectionCount', + 'pendingHostAcknowledgementCount')) { + if ([int]$transitOwnership.$field -ne 0) { + $failures.Add(( + "${SessionLabel}/${name}: transitOwnership.$field={0}, expected zero at a stable checkpoint" -f + $transitOwnership.$field)) + } + } if ($resources.pendingLiveTeardowns -ne 0) { $failures.Add("${SessionLabel}/${name}: $($resources.pendingLiveTeardowns) live teardown(s) pending") }