From a9a822f206cc6021bc099988144488f3bd7c397b Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 27 Jul 2026 00:43:26 +0200 Subject: [PATCH] refactor(runtime): unify generation reset for direct hosts Move canonical per-session teardown into one retryable Runtime transaction, reduce App reset to projection acknowledgements, and prove the same GameRuntime graph through deterministic no-window lifecycle, gameplay, portal, fault, reconnect, and isolation gates.\n\nCo-authored-by: Codex --- .../Composition/SessionPlayerComposition.cs | 1 + .../SelectionInteractionController.cs | 23 + .../Net/LiveSessionResetManifest.cs | 98 +-- src/AcDream.App/Net/LiveSessionResetPlan.cs | 21 +- .../Net/LiveSessionRuntimeFactory.cs | 61 +- .../LocalPlayerTeleportController.cs | 28 +- .../Streaming/WorldRevealCoordinator.cs | 12 +- .../UI/ItemInteractionController.cs | 41 +- src/AcDream.App/World/LiveEntityRuntime.cs | 81 +- .../Entities/RuntimeEntityObjectLifetime.cs | 46 +- src/AcDream.Runtime/GameRuntime.cs | 24 +- .../RuntimeLocalPlayerMovementState.cs | 20 + src/AcDream.Runtime/RuntimeGenerationReset.cs | 387 +++++++++ .../Session/LiveSessionController.cs | 58 +- .../Session/LiveSessionHost.cs | 9 +- .../Session/LiveSessionLifecycleHost.cs | 6 +- .../Net/LiveSessionResetPlanTests.cs | 208 ++--- .../LiveSessionShutdownIntegrationTests.cs | 4 +- .../Runtime/CurrentGameRuntimeAdapterTests.cs | 22 +- .../LocalPlayerTeleportControllerTests.cs | 3 + .../NoWindowGameRuntimeHostTests.cs | 650 ++++++++++++++ .../RuntimeGenerationResetTests.cs | 359 ++++++++ .../RuntimeSimulationFixtureHostTests.cs | 52 +- .../Session/LiveSessionControllerTests.cs | 33 +- .../Session/LiveSessionHostTests.cs | 2 +- .../Session/LiveSessionLifecycleHostTests.cs | 4 +- .../RuntimeLiveSessionNoWindowTests.cs | 2 +- .../Support/NoWindowGameRuntimeHost.cs | 797 ++++++++++++++++++ 28 files changed, 2707 insertions(+), 345 deletions(-) create mode 100644 src/AcDream.Runtime/RuntimeGenerationReset.cs create mode 100644 tests/AcDream.Runtime.Tests/NoWindowGameRuntimeHostTests.cs create mode 100644 tests/AcDream.Runtime.Tests/RuntimeGenerationResetTests.cs create mode 100644 tests/AcDream.Runtime.Tests/Support/NoWindowGameRuntimeHost.cs diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs index e9fbc226..7ffe2f50 100644 --- a/src/AcDream.App/Composition/SessionPlayerComposition.cs +++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs @@ -815,6 +815,7 @@ internal sealed class SessionPlayerCompositionPhase d.PlayerController, d.WorldOrigin), new LiveSessionDomainRuntime( + d.Runtime, d.EntityObjects, d.Character, d.Actions, diff --git a/src/AcDream.App/Interaction/SelectionInteractionController.cs b/src/AcDream.App/Interaction/SelectionInteractionController.cs index ca95ec1e..8085d957 100644 --- a/src/AcDream.App/Interaction/SelectionInteractionController.cs +++ b/src/AcDream.App/Interaction/SelectionInteractionController.cs @@ -476,6 +476,29 @@ internal sealed class SelectionInteractionController failures); } + /// + /// Retires only App approach and retained item-interaction presentation. + /// Runtime resets the canonical interaction and selection owners once at + /// the shared generation boundary. + /// + internal void ResetGenerationPresentation() + { + List failures = []; + try { CancelPendingApproach(); } + catch (Exception error) { failures.Add(error); } + try { _items.ResetGenerationPresentation(); } + catch (Exception error) { failures.Add(error); } + try { _approachCompletions.Clear(); } + catch (Exception error) { failures.Add(error); } + + if (failures.Count != 0) + { + throw new AggregateException( + "One or more selection-interaction presentation reset stages failed.", + failures); + } + } + private bool ValidatePickupTarget(uint serverGuid, bool showToast) { if (_query.IsCreature(serverGuid)) diff --git a/src/AcDream.App/Net/LiveSessionResetManifest.cs b/src/AcDream.App/Net/LiveSessionResetManifest.cs index 39688f34..2cb99001 100644 --- a/src/AcDream.App/Net/LiveSessionResetManifest.cs +++ b/src/AcDream.App/Net/LiveSessionResetManifest.cs @@ -1,6 +1,8 @@ namespace AcDream.App.Net; using AcDream.App.World; +using AcDream.Runtime; +using AcDream.Runtime.Entities; /// /// Focused owner operations composed by the App host for one character-session @@ -11,31 +13,18 @@ internal sealed class LiveSessionResetBindings { public required Action MouseCapture { get; init; } public required Action PlayerPresentation { get; init; } - public required Action TeleportTransit { get; init; } + public required Action TeleportPresentation { get; init; } public required Action SessionDialogs { get; init; } - public required Action ChatCommandTargets { get; init; } public required Action SettingsCharacterContext { get; init; } public required Action EquippedChildren { get; init; } - public required Action ExternalContainer { get; init; } - public required Action InteractionAndSelection { get; init; } - public required Action InventoryTransactions { get; init; } + public required Action InteractionPresentation { get; init; } public required Action SelectionPresentation { get; init; } - public required Action ObjectTable { get; init; } - public required Action Spellbook { get; init; } - public required Action MagicRuntime { get; init; } - public required Action CombatAttack { get; init; } - public required Action CombatState { get; init; } - public required Action ItemMana { get; init; } - public required Action LocalPlayer { get; init; } - public required Action Friends { get; init; } - public required Action Squelch { get; init; } - public required Action TurbineChat { get; init; } public required Action ParticleVisibility { get; init; } public required Action InboundEventFifo { get; init; } public required Action LiveLiveness { get; init; } - public required Action LiveRuntime { get; init; } - public required Action RenderSceneProjection { get; init; } - public required Action SessionIdentity { get; init; } + public required Action RuntimeGeneration { get; init; } + public required Action SessionIdentityPresentation + { get; init; } public required Action RemoteTeleport { get; init; } public required Action NetworkEffects { get; init; } public required Action AnimationHookFrames { get; init; } @@ -59,38 +48,23 @@ internal static class LiveSessionResetManifest [ new("mouse capture", bindings.MouseCapture), new("player presentation", bindings.PlayerPresentation), - new("teleport transit", bindings.TeleportTransit), + new("teleport presentation", bindings.TeleportPresentation), new("session dialogs", bindings.SessionDialogs), - new("chat command targets", bindings.ChatCommandTargets), new("settings character context", bindings.SettingsCharacterContext), // Attachment projections own GL-backed registrations and must leave // before canonical live GUID records are released. new("equipped children", bindings.EquippedChildren), - new("external container", bindings.ExternalContainer), - new("interaction and selection", bindings.InteractionAndSelection), - new("inventory transactions", bindings.InventoryTransactions), + new("interaction presentation", bindings.InteractionPresentation), new("selection presentation", bindings.SelectionPresentation), - new("object table", bindings.ObjectTable), - new("spellbook", bindings.Spellbook), - new("magic runtime", bindings.MagicRuntime), - new("combat attack", bindings.CombatAttack), - new("combat state", bindings.CombatState), - new("item mana", bindings.ItemMana), - new("local player", bindings.LocalPlayer), - new("friends", bindings.Friends), - new("squelch", bindings.Squelch), - new("turbine chat", bindings.TurbineChat), new("particle visibility", bindings.ParticleVisibility), new("inbound event fifo", bindings.InboundEventFifo), new("live liveness", bindings.LiveLiveness), - new("live runtime", bindings.LiveRuntime), - // Canonical teardown appends exact projection removals. Drain them - // before identity changes so no prior-session scene record or - // journal entry can carry into the next character generation. - new("render scene projection", bindings.RenderSceneProjection), + new("runtime generation", bindings.RuntimeGeneration), // Identity must remain A until live teardown has converged so // player-specific teardown can still classify the old record. - new("session identity", bindings.SessionIdentity), + new( + "session identity presentation", + bindings.SessionIdentityPresentation), new("remote teleport", bindings.RemoteTeleport), // F754/F755 can precede CreateObject, so pending network effects // must clear even when no LiveEntityRecord was constructed. @@ -102,31 +76,33 @@ internal static class LiveSessionResetManifest } } -/// Shared convergence gate for canonical live runtime teardown. -internal static class LiveSessionEntityRuntimeReset +/// +/// App's narrow borrowed projection acknowledgement for Runtime's canonical +/// generation-reset transaction. It owns no reset cursor or entity identity. +/// +internal sealed class GraphicalRuntimeGenerationResetHost + : IRuntimeGenerationResetHost { - public static void ClearAndRequireConvergence(LiveEntityRuntime? runtime) + private readonly LiveEntityRuntime _entities; + private readonly Action _drainRenderProjection; + + public GraphicalRuntimeGenerationResetHost( + LiveEntityRuntime entities, + Action drainRenderProjection) { - if (runtime is null) - return; - runtime.Clear(); - RequireConvergence(runtime); + _entities = entities + ?? throw new ArgumentNullException(nameof(entities)); + _drainRenderProjection = drainRenderProjection + ?? throw new ArgumentNullException(nameof(drainRenderProjection)); } - public static void RequireConvergence(LiveEntityRuntime? runtime) - { - if (runtime is null) - return; - if (runtime.Count == 0 - && runtime.PendingTeardownCount == 0 - && runtime.MaterializedCount == 0) - { - return; - } + public void RetireEntityProjection( + RuntimeEntityRecord entity) => + _entities.RetireGenerationProjection(entity); - throw new InvalidOperationException( - "The live-entity runtime has not converged after session clear " + - $"(records={runtime.Count}, pending={runtime.PendingTeardownCount}, " + - $"materialized={runtime.MaterializedCount})."); - } + public void DrainEntityProjectionBoundary() => + _drainRenderProjection(); + + public void CompleteEntityProjectionRetirement() => + _entities.CompleteGenerationProjectionRetirement(); } diff --git a/src/AcDream.App/Net/LiveSessionResetPlan.cs b/src/AcDream.App/Net/LiveSessionResetPlan.cs index dbfba133..a7e61dbf 100644 --- a/src/AcDream.App/Net/LiveSessionResetPlan.cs +++ b/src/AcDream.App/Net/LiveSessionResetPlan.cs @@ -1,7 +1,19 @@ namespace AcDream.App.Net; -/// One named owner operation in the live-session reset transaction. -internal sealed record LiveSessionResetStage(string Name, Action Reset); +using AcDream.Runtime; + +/// One named host operation around Runtime's generation reset. +internal sealed record LiveSessionResetStage( + string Name, + Action Reset) +{ + public LiveSessionResetStage(string name, Action reset) + : this( + name, + _ => (reset ?? throw new ArgumentNullException(nameof(reset)))()) + { + } +} /// /// Executes every session-state reset stage even when an earlier owner fails. @@ -35,6 +47,9 @@ internal sealed class LiveSessionResetPlan Array.ConvertAll(_stages, static stage => stage.Name); public void Execute() + => Execute(default); + + public void Execute(RuntimeGenerationToken retiringGeneration) { if (Interlocked.Exchange(ref _executing, 1) != 0) throw new InvalidOperationException( @@ -47,7 +62,7 @@ internal sealed class LiveSessionResetPlan { try { - stage.Reset(); + stage.Reset(retiringGeneration); } catch (Exception error) { diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs index bacaabc3..d2bb6fc9 100644 --- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs +++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs @@ -23,6 +23,7 @@ using AcDream.Core.Social; using AcDream.Core.Spells; using AcDream.Core.World; using AcDream.Content; +using AcDream.Runtime; using AcDream.Runtime.Entities; using AcDream.Runtime.Gameplay; using AcDream.Runtime.Session; @@ -39,6 +40,7 @@ internal sealed record LiveSessionPlayerRuntime( LiveWorldOriginState WorldOrigin); internal sealed record LiveSessionDomainRuntime( + GameRuntime Runtime, RuntimeEntityObjectLifetime EntityObjects, RuntimeCharacterState Character, RuntimeActionState Actions, @@ -122,12 +124,18 @@ internal sealed class LiveSessionRuntimeFactory { ArgumentNullException.ThrowIfNull(controller); ArgumentNullException.ThrowIfNull(connectOptions); + var resetHost = new GraphicalRuntimeGenerationResetHost( + _world.LiveEntities, + () => _world.RenderSceneShadow?.DrainUpdateBoundary()); + LiveSessionResetPlan reset = + LiveSessionResetManifest.Create( + CreateResetBindings(resetHost)); return new LiveSessionHost(controller, new LiveSessionHostBindings( Routing: new( CreateEventRouter, session => _commands.Attach(new LiveSessionCommandRouter( CreateCommandBindings(session)))), - Reset: LiveSessionResetManifest.Create(CreateResetBindings()).Execute, + Reset: reset.Execute, Selection: new( SetPlayerIdentity: id => _player.Identity.ServerGuid = id, SetVitalsIdentity: id => _ui.Vitals?.SetLocalPlayerGuid(id), @@ -152,37 +160,26 @@ internal sealed class LiveSessionRuntimeFactory connectOptions); } - private LiveSessionResetBindings CreateResetBindings() => new() + private LiveSessionResetBindings CreateResetBindings( + IRuntimeGenerationResetHost resetHost) => new() { MouseCapture = _interaction.GameplayInput.ResetSession, PlayerPresentation = ResetPlayerPresentation, - TeleportTransit = _world.Teleport.ResetSession, + TeleportPresentation = + _world.Teleport.ResetGenerationPresentation, SessionDialogs = () => _ui.RetailUi?.ResetSessionTransientUi(), - ChatCommandTargets = _domain.Communication.ResetCommandTargets, SettingsCharacterContext = _interaction.Settings.RestoreDefaultCharacterContext, EquippedChildren = _world.EquippedChildren.Clear, - ExternalContainer = _domain.Inventory.ResetExternalContainer, - InteractionAndSelection = _interaction.SelectionInteractions.ResetSession, - InventoryTransactions = _domain.Inventory.ResetTransactions, + InteractionPresentation = + _interaction.SelectionInteractions.ResetGenerationPresentation, SelectionPresentation = _world.SelectionScene.Reset, - ObjectTable = _domain.EntityObjects.ClearObjects, - Spellbook = _domain.Character.ResetSpellbook, - MagicRuntime = () => _ui.Magic?.Reset(), - CombatAttack = _interaction.CombatAttack.ResetSession, - CombatState = _domain.Actions.Combat.Clear, - ItemMana = _domain.Inventory.ResetItemMana, - LocalPlayer = _domain.Character.ResetLocalPlayer, - Friends = _domain.Communication.ResetFriends, - Squelch = _domain.Communication.ResetSquelch, - TurbineChat = _domain.Communication.ResetNegotiatedChannels, ParticleVisibility = _world.ParticleVisibility.Reset, InboundEventFifo = _world.InboundEvents.Clear, LiveLiveness = _world.Liveness.Clear, - LiveRuntime = ResetLiveRuntime, - RenderSceneProjection = - () => _world.RenderSceneShadow?.DrainUpdateBoundary(), - SessionIdentity = ResetIdentity, + RuntimeGeneration = generation => + _domain.Runtime.ResetGeneration(generation, resetHost), + SessionIdentityPresentation = ResetIdentityPresentation, RemoteTeleport = _world.RemoteTeleport.Clear, NetworkEffects = _world.EntityEffects.ClearNetworkState, AnimationHookFrames = _world.AnimationHookFrames.Clear, @@ -196,28 +193,28 @@ internal sealed class LiveSessionRuntimeFactory _world.SpawnClaims.Reset(); } - private void ResetLiveRuntime() => - LiveSessionEntityRuntimeReset.ClearAndRequireConvergence( - _world.LiveEntities); - - private void ResetIdentity() + private void ResetIdentityPresentation( + RuntimeGenerationToken retiringGeneration) { - LiveSessionEntityRuntimeReset.RequireConvergence(_world.LiveEntities); + RuntimeGenerationResetSnapshot reset = + _domain.Runtime.GenerationReset.CaptureSnapshot(); + if (reset.IsActive + || reset.LastCompletedGeneration != retiringGeneration) + { + throw new InvalidOperationException( + $"Runtime generation {retiringGeneration.Value} has not " + + "converged before App identity projection teardown."); + } // PlayerModule::Clear @ 0x005D48A0 clears shortcuts, desired // components, and character option objects. CPlayerSystem::Begin // @ 0x0055D410 resets player identity and session fields. The option // default comes from PlayerModule::PlayerModule @ 0x005D51F0. - _player.Identity.ServerGuid = 0u; _ui.Vitals?.SetLocalPlayerGuid(0u); - _domain.Communication.ResetChatIdentity(); EntityVanishProbe.PlayerGuid = 0u; _interaction.Settings.ResetActiveCharacterKey(); - _domain.Character.Options.ResetSession(); - _domain.Character.MovementSkills.ResetSession(); _world.NetworkUpdates.ResetSessionState(); _world.Hydration.ResetSessionState(); - _domain.Inventory.ResetPlayerSnapshots(); _ui.Paperdoll?.ResetSession(); // X/Y are ignored until the next logical player CreateObject claims a diff --git a/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs b/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs index 50a8b698..fc44888b 100644 --- a/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs +++ b/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs @@ -23,6 +23,8 @@ internal interface ILocalPlayerTeleportNetworkSink bool teleportTimestampAdvanced); void ResetSession(); + + void ResetGenerationPresentation(); } /// @@ -62,6 +64,9 @@ internal sealed class DeferredLocalPlayerTeleportNetworkSink public void ResetSession() => Required().ResetSession(); + public void ResetGenerationPresentation() => + Required().ResetGenerationPresentation(); + private ILocalPlayerTeleportNetworkSink Required() => _inner ?? throw new InvalidOperationException( "The local teleport sink was used before composition completed."); @@ -567,7 +572,17 @@ internal sealed class LocalPlayerTeleportController public void ResetSession() { ThrowIfDisposed(); - ResetTransit(clearSession: true); + ResetTransit( + clearSession: true, + resetCanonicalTransit: true); + } + + public void ResetGenerationPresentation() + { + ThrowIfDisposed(); + ResetTransit( + clearSession: true, + resetCanonicalTransit: false); } public Matrix4x4 ApplyViewPlane(Matrix4x4 projection) => @@ -718,7 +733,9 @@ internal sealed class LocalPlayerTeleportController return true; } - private long ResetTransit(bool clearSession) + private long ResetTransit( + bool clearSession, + bool resetCanonicalTransit = false) { long generation = checked(++_lifetimeGeneration); @@ -733,7 +750,12 @@ internal sealed class LocalPlayerTeleportController return generation; if (clearSession) - _worldReveal.ResetSession(); + { + if (resetCanonicalTransit) + _worldReveal.ResetSession(); + else + _worldReveal.ResetHostSession(); + } else { _transit.EndTeleport(); diff --git a/src/AcDream.App/Streaming/WorldRevealCoordinator.cs b/src/AcDream.App/Streaming/WorldRevealCoordinator.cs index 6dd48fd6..e23bf3e0 100644 --- a/src/AcDream.App/Streaming/WorldRevealCoordinator.cs +++ b/src/AcDream.App/Streaming/WorldRevealCoordinator.cs @@ -286,12 +286,22 @@ internal sealed class WorldRevealCoordinator } public void ResetSession() + { + ResetHostSession(); + _transit.ResetSession(); + } + + /// + /// Retires only App streaming/presentation acknowledgements. The shared + /// Runtime generation-reset transaction clears canonical transit state + /// after this host boundary has converged. + /// + internal void ResetHostSession() { long generation = _transit.Snapshot.Generation; if (generation != 0) _transit.Cancel(generation); RetryPendingHostWork(); - _transit.ResetSession(); } /// diff --git a/src/AcDream.App/UI/ItemInteractionController.cs b/src/AcDream.App/UI/ItemInteractionController.cs index 8ac8f607..db968d08 100644 --- a/src/AcDream.App/UI/ItemInteractionController.cs +++ b/src/AcDream.App/UI/ItemInteractionController.cs @@ -1182,27 +1182,44 @@ public sealed class ItemInteractionController : IDisposable /// same numeric GUID can identify a different object. /// public void ResetSession() + => ResetSessionCore(resetRuntime: true); + + /// + /// Clears only retained UI transaction presentation. Runtime's canonical + /// transaction, interaction, and busy owners are reset once by the shared + /// generation-reset transaction. + /// + internal void ResetGenerationPresentation() + => ResetSessionCore(resetRuntime: false); + + private void ResetSessionCore(bool resetRuntime) { PendingBackpackPlacement? pendingPlacement = _pendingBackpackPlacement; _pendingBackpackPlacement = null; - // Runtime owns the transaction reset. This controller preserves the - // pre-J4 single UI notification emitted by InteractionState below. - _transactions.StateChanged -= OnTransactionStateChanged; - try + if (resetRuntime) { - _runtimeTransactions.ResetSession(); - } - finally - { - if (!_disposed) - _transactions.StateChanged += OnTransactionStateChanged; + // Runtime owns the transaction reset. This controller preserves + // the pre-J4 single UI notification emitted by InteractionState. + _transactions.StateChanged -= OnTransactionStateChanged; + try + { + _runtimeTransactions.ResetSession(); + } + finally + { + if (!_disposed) + _transactions.StateChanged += OnTransactionStateChanged; + } } _consumedPrimaryClickTarget = 0u; _consumedPrimaryClickMs = long.MinValue / 2; List failures = []; - try { _interactionState.ResetSession(); } - catch (Exception error) { failures.Add(error); } + if (resetRuntime) + { + try { _interactionState.ResetSession(); } + catch (Exception error) { failures.Add(error); } + } if (pendingPlacement is { } pending) DispatchAll(PendingBackpackPlacementCancelled, pending, failures); diff --git a/src/AcDream.App/World/LiveEntityRuntime.cs b/src/AcDream.App/World/LiveEntityRuntime.cs index c9dbc111..378164ef 100644 --- a/src/AcDream.App/World/LiveEntityRuntime.cs +++ b/src/AcDream.App/World/LiveEntityRuntime.cs @@ -2230,6 +2230,76 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource IsCurrentRecord(expectedRecord) && expectedRecord.CreateIntegrationVersion == expectedCreateIntegrationVersion; + /// + /// Retires only the graphical sidecar for an exact Runtime-owned + /// incarnation. The Runtime generation-reset transaction retains and + /// completes the canonical tombstone after this acknowledgement returns. + /// + internal void RetireGenerationProjection( + RuntimeEntityRecord canonical) + { + ArgumentNullException.ThrowIfNull(canonical); + if (_isClearing || _isRegisteringResources) + { + throw new InvalidOperationException( + _isClearing + ? "Live entity projection teardown is already in progress." + : "Live entity projection teardown cannot begin inside atomic resource registration."); + } + + LiveEntityRecord? record = null; + if (_projections.TryGet(canonical, out LiveEntityRecord active)) + { + if (!_projections.RemoveActive(active)) + { + throw new InvalidOperationException( + $"Exact App projection for 0x{canonical.ServerGuid:X8}/{canonical.Incarnation} could not be retired during generation reset."); + } + _projections.RetainTeardown(active); + record = active; + } + else if (_projections.TryGetTeardown( + canonical, + out LiveEntityRecord retained)) + { + record = retained; + } + + if (record is null) + return; + + _isClearing = true; + try + { + TearDownRecord(record, completeCanonical: false); + _projections.ReleaseTeardown(record); + } + finally + { + _isClearing = false; + } + } + + /// + /// Completes the App-only projection boundary after Runtime has retired + /// every exact canonical incarnation. No canonical owner is mutated here. + /// + internal void CompleteGenerationProjectionRetirement() + { + if (_projections.ActiveCount != 0 + || _projections.TeardownCount != 0) + { + throw new InvalidOperationException( + "Live entity projection storage has not converged at the generation-reset boundary."); + } + + _spatialAnimations.Clear(); + _physics.ClearSpatialWorksets(); + _projections.ClearConverged(); + _spatial.ClearLiveEntityLifetimeState(); + _sessionClearPendingFinalization = false; + } + public void Clear() { if (_isClearing || _isRegisteringResources) @@ -2670,7 +2740,9 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource _sessionClearPendingFinalization = false; } - private void TearDownRecord(LiveEntityRecord record) + private void TearDownRecord( + LiveEntityRecord record, + bool completeCanonical = true) { if (record.TeardownInProgress) throw new InvalidOperationException( @@ -2730,8 +2802,11 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource if (record.WorldEntity is not null) _ = RequireProjectionKey(record); - _entityObjects.CompleteProjectionRetirement( - record.Canonical); + if (completeCanonical) + { + _entityObjects.CompleteProjectionRetirement( + record.Canonical); + } record.AnimationRuntime = null; record.EffectProfile = null; diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs index 19b8dedd..c1444257 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs @@ -968,15 +968,55 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable _sessionClearInProgress = true; Entities.BeginSessionClear(); - RuntimeEntityRecord[] retired = Entities.ActiveRecords.ToArray(); - foreach (RuntimeEntityRecord canonical in retired) + RuntimeEntityRecord[] active = Entities.ActiveRecords.ToArray(); + foreach (RuntimeEntityRecord canonical in active) { if (!Entities.RemoveActive(canonical)) continue; Entities.RetainTeardown(canonical); PublishEntity(RuntimeEntityChange.Deleted, canonical); } - return retired; + return active; + } + + public IReadOnlyList + CaptureSessionClearRetirements() + { + EnsureNotDisposed(); + if (!_sessionClearInProgress) + { + throw new InvalidOperationException( + "Session-clear retirements are only available while the " + + "canonical clear transaction is active."); + } + return Entities.TeardownRecords.ToArray(); + } + + /// + /// Completes one exact incarnation retained by + /// after the borrowed host has retired its + /// presentation projection. Failure keeps the tombstone and local + /// identity intact so the same transaction cursor can retry this exact + /// record without reconstructing the retirement set. + /// + public void CompleteSessionEntityRetirement( + RuntimeEntityRecord canonical) + { + EnsureNotDisposed(); + ArgumentNullException.ThrowIfNull(canonical); + if (!_sessionClearInProgress + || !Entities.TryGetTeardown( + canonical.ServerGuid, + canonical.Incarnation, + out RuntimeEntityRecord retained) + || !ReferenceEquals(retained, canonical)) + { + throw new InvalidOperationException( + $"Live entity 0x{canonical.ServerGuid:X8}/{canonical.Incarnation} is not retained by the active session-clear transaction."); + } + + CompleteProjectionRetirement(canonical); + Entities.ReleaseTeardown(canonical); } public bool CompleteSessionClearIfConverged() diff --git a/src/AcDream.Runtime/GameRuntime.cs b/src/AcDream.Runtime/GameRuntime.cs index 6c00445c..f6b5be4f 100644 --- a/src/AcDream.Runtime/GameRuntime.cs +++ b/src/AcDream.Runtime/GameRuntime.cs @@ -58,6 +58,7 @@ public readonly record struct GameRuntimeOwnershipSnapshot( RuntimeSimulationOwnershipSnapshot Simulation, RuntimeWorldEnvironmentOwnershipSnapshot Environment, RuntimeWorldTransitOwnershipSnapshot Transit, + RuntimeGenerationResetSnapshot GenerationReset, GameRuntimeEventOwnershipSnapshot Events) { public bool IsConverged => @@ -70,6 +71,7 @@ public readonly record struct GameRuntimeOwnershipSnapshot( && PlayerIdentity.IsConverged && Simulation.IsConverged && Transit.IsSessionIdle + && GenerationReset.IsConverged && Events.IsConverged; } @@ -241,8 +243,19 @@ public sealed class GameRuntime context, faultInjection); + var generationReset = new RuntimeGenerationReset( + transit, + context.Communication, + context.Inventory, + context.Actions, + context.Movement, + context.EntityObjects, + context.Character, + context.PlayerIdentity); + context.EntityObjects.BindEventContext( - () => context.Session.Generation, + () => generationReset.ActiveRetiringGeneration + ?? context.Session.Generation, () => clock.FrameNumber); context.Events = new GameRuntimeEventHub( context.EntityObjects, @@ -264,6 +277,7 @@ public sealed class GameRuntime ActionOwner = context.Actions; EnvironmentOwner = environment; TransitOwner = transit; + GenerationReset = generationReset; _events = context.Events; construction.Complete(); } @@ -285,6 +299,7 @@ public sealed class GameRuntime public RuntimeLocalPlayerMovementState MovementOwner { get; } public RuntimeWorldEnvironmentState EnvironmentOwner { get; } public RuntimeWorldTransitState TransitOwner { get; } + public RuntimeGenerationReset GenerationReset { get; } public RuntimeGenerationToken Generation => Session.Generation; @@ -347,6 +362,11 @@ public sealed class GameRuntime Portal.Snapshot, Portal.Ownership); + public void ResetGeneration( + RuntimeGenerationToken retiringGeneration, + IRuntimeGenerationResetHost host) => + GenerationReset.Reset(retiringGeneration, host); + public IDisposable Subscribe(IRuntimeEventObserver observer) { lock (_lifetimeGate) @@ -393,6 +413,7 @@ public sealed class GameRuntime MovementOwner), EnvironmentOwner.CaptureOwnership(), TransitOwner.CaptureOwnership(), + GenerationReset.CaptureSnapshot(), _events.CaptureOwnership()); } } @@ -534,6 +555,7 @@ public sealed class GameRuntime StopSession(); return Session.CaptureOwnership().IsConverged; case 3: + GenerationReset.DrainPending(); TransitOwner.ResetSession(); return TransitOwner.CaptureOwnership().IsSessionIdle; case 4: diff --git a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs index e6f5463a..c7e59f2d 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs @@ -134,6 +134,26 @@ public sealed class RuntimeLocalPlayerMovementState Interlocked.Increment(ref _revision); } + /// + /// Retires every character-generation movement owner while keeping the + /// process-scoped Runtime root reusable for the next session. + /// Graphical hosts release their camera/physics projections first; direct + /// hosts have no parallel projection to coordinate. + /// + public void ResetSession() + { + ObjectDisposedException.ThrowIf(_disposed, this); + bool changed = + _autoRunActive + || _controller is not null + || _preparingMotionOwner is not null; + _autoRunActive = false; + _controller = null; + _preparingMotionOwner = null; + if (changed) + Interlocked.Increment(ref _revision); + } + public RuntimeLocalMovementOwnershipSnapshot CaptureOwnership() => new( _disposed, diff --git a/src/AcDream.Runtime/RuntimeGenerationReset.cs b/src/AcDream.Runtime/RuntimeGenerationReset.cs new file mode 100644 index 00000000..419ad8e9 --- /dev/null +++ b/src/AcDream.Runtime/RuntimeGenerationReset.cs @@ -0,0 +1,387 @@ +using AcDream.Runtime.Entities; +using AcDream.Runtime.Gameplay; +using AcDream.Runtime.World; + +namespace AcDream.Runtime; + +/// +/// The only host-specific edge in a character-generation reset. The host +/// retires presentation borrowed from an exact Runtime incarnation; Runtime +/// retains the retirement set, cursor, canonical cleanup, and generation. +/// +public interface IRuntimeGenerationResetHost +{ + void RetireEntityProjection(RuntimeEntityRecord entity); + + void DrainEntityProjectionBoundary(); + + void CompleteEntityProjectionRetirement(); +} + +public enum RuntimeGenerationResetStage +{ + None = 0, + Transit = 1, + CommandTargets = 2, + ExternalContainer = 3, + Actions = 4, + Movement = 5, + ObjectTable = 6, + Character = 7, + ItemMana = 8, + Friends = 9, + Squelch = 10, + NegotiatedChannels = 11, + BeginEntityRetirement = 12, + RetireEntities = 13, + DrainHostProjection = 14, + CompleteCanonicalEntities = 15, + CompleteHostProjection = 16, + ChatIdentity = 17, + PlayerSnapshots = 18, + PlayerIdentity = 19, + Complete = 20, +} + +public readonly record struct RuntimeGenerationResetSnapshot( + bool IsActive, + bool IsExecuting, + RuntimeGenerationToken RetiringGeneration, + RuntimeGenerationToken LastCompletedGeneration, + RuntimeGenerationResetStage Stage, + int RetirementCount, + int RetirementCursor, + bool CurrentProjectionAcknowledged, + long TransactionId) +{ + public bool IsConverged => + !IsActive + && !IsExecuting; +} + +public sealed class RuntimeGenerationResetStageException( + RuntimeGenerationToken retiringGeneration, + RuntimeGenerationResetStage stage, + Exception innerException) : Exception( + $"Runtime generation {retiringGeneration.Value} reset stage " + + $"'{stage}' did not converge.", + innerException) +{ + public RuntimeGenerationToken RetiringGeneration { get; } = + retiringGeneration; + + public RuntimeGenerationResetStage Stage { get; } = stage; +} + +/// +/// One persisted, retryable reset transaction for every canonical owner in a +/// reusable . A failed call retains the exact suffix: +/// completed stages and exact-incarnation retirements never replay. +/// +public sealed class RuntimeGenerationReset +{ + private readonly RuntimeWorldTransitState _transit; + private readonly RuntimeCommunicationState _communication; + private readonly RuntimeInventoryState _inventory; + private readonly RuntimeActionState _actions; + private readonly RuntimeLocalPlayerMovementState _movement; + private readonly RuntimeEntityObjectLifetime _entityObjects; + private readonly RuntimeCharacterState _character; + private readonly RuntimeLocalPlayerIdentityState _identity; + private ResetState? _state; + private RuntimeGenerationToken _lastCompletedGeneration; + private bool _hasCompletedGeneration; + private bool _executing; + private long _nextTransactionId; + + internal RuntimeGenerationReset( + RuntimeWorldTransitState transit, + RuntimeCommunicationState communication, + RuntimeInventoryState inventory, + RuntimeActionState actions, + RuntimeLocalPlayerMovementState movement, + RuntimeEntityObjectLifetime entityObjects, + RuntimeCharacterState character, + RuntimeLocalPlayerIdentityState identity) + { + _transit = transit ?? throw new ArgumentNullException(nameof(transit)); + _communication = communication + ?? throw new ArgumentNullException(nameof(communication)); + _inventory = inventory + ?? throw new ArgumentNullException(nameof(inventory)); + _actions = actions ?? throw new ArgumentNullException(nameof(actions)); + _movement = movement + ?? throw new ArgumentNullException(nameof(movement)); + _entityObjects = entityObjects + ?? throw new ArgumentNullException(nameof(entityObjects)); + _character = character + ?? throw new ArgumentNullException(nameof(character)); + _identity = identity + ?? throw new ArgumentNullException(nameof(identity)); + } + + public RuntimeGenerationToken? ActiveRetiringGeneration => + _state?.Generation; + + public RuntimeGenerationResetSnapshot CaptureSnapshot() + { + ResetState? state = _state; + return state is null + ? new RuntimeGenerationResetSnapshot( + false, + _executing, + default, + _lastCompletedGeneration, + _hasCompletedGeneration + ? RuntimeGenerationResetStage.Complete + : RuntimeGenerationResetStage.None, + 0, + 0, + false, + 0) + : new RuntimeGenerationResetSnapshot( + true, + _executing, + state.Generation, + _lastCompletedGeneration, + state.Stage, + state.Retirements?.Length ?? 0, + state.RetirementCursor, + state.CurrentProjectionAcknowledged, + state.TransactionId); + } + + public void Reset( + RuntimeGenerationToken retiringGeneration, + IRuntimeGenerationResetHost host) + { + ArgumentNullException.ThrowIfNull(host); + if (_executing) + { + throw new InvalidOperationException( + "Runtime generation reset cannot run concurrently or reentrantly."); + } + + ResetState state = AcquireState(retiringGeneration, host); + if (state.Stage is RuntimeGenerationResetStage.Complete) + return; + + _executing = true; + try + { + Drain(state); + } + catch (Exception error) + { + throw new RuntimeGenerationResetStageException( + state.Generation, + state.Stage, + error); + } + finally + { + _executing = false; + } + } + + internal void DrainPending() + { + ResetState? state = _state; + if (state is null) + return; + Reset(state.Generation, state.Host); + } + + private ResetState AcquireState( + RuntimeGenerationToken generation, + IRuntimeGenerationResetHost host) + { + if (_state is { } pending) + { + if (pending.Generation != generation) + { + throw new InvalidOperationException( + $"Runtime generation {pending.Generation.Value} reset " + + $"must converge before generation {generation.Value} can reset."); + } + if (!ReferenceEquals(pending.Host, host)) + { + throw new InvalidOperationException( + "An in-progress Runtime generation reset cannot replace " + + "its borrowed projection host."); + } + return pending; + } + + if (_hasCompletedGeneration) + { + if (generation == _lastCompletedGeneration) + return ResetState.Completed(generation, host); + if (generation.Value < _lastCompletedGeneration.Value) + { + throw new InvalidOperationException( + $"Runtime generation {generation.Value} reset is stale; " + + $"generation {_lastCompletedGeneration.Value} already converged."); + } + } + + var created = new ResetState( + generation, + host, + checked(++_nextTransactionId)); + _state = created; + return created; + } + + private void Drain(ResetState state) + { + while (state.Stage is not RuntimeGenerationResetStage.Complete) + { + switch (state.Stage) + { + case RuntimeGenerationResetStage.Transit: + Advance(state, _transit.ResetSession); + break; + case RuntimeGenerationResetStage.CommandTargets: + Advance(state, _communication.ResetCommandTargets); + break; + case RuntimeGenerationResetStage.ExternalContainer: + Advance(state, _inventory.ResetExternalContainer); + break; + case RuntimeGenerationResetStage.Actions: + Advance(state, _actions.ResetSession); + break; + case RuntimeGenerationResetStage.Movement: + Advance(state, _movement.ResetSession); + break; + case RuntimeGenerationResetStage.ObjectTable: + Advance(state, _entityObjects.ClearObjects); + break; + case RuntimeGenerationResetStage.Character: + Advance(state, _character.ResetSession); + break; + case RuntimeGenerationResetStage.ItemMana: + Advance(state, _inventory.ResetItemMana); + break; + case RuntimeGenerationResetStage.Friends: + Advance(state, _communication.ResetFriends); + break; + case RuntimeGenerationResetStage.Squelch: + Advance(state, _communication.ResetSquelch); + break; + case RuntimeGenerationResetStage.NegotiatedChannels: + Advance( + state, + _communication.ResetNegotiatedChannels); + break; + case RuntimeGenerationResetStage.BeginEntityRetirement: + _ = _entityObjects.BeginSessionClear(); + state.Retirements = _entityObjects + .CaptureSessionClearRetirements() + .ToArray(); + state.Stage = RuntimeGenerationResetStage.RetireEntities; + break; + case RuntimeGenerationResetStage.RetireEntities: + RetireCurrentEntity(state); + break; + case RuntimeGenerationResetStage.DrainHostProjection: + state.Host.DrainEntityProjectionBoundary(); + state.Stage = + RuntimeGenerationResetStage.CompleteCanonicalEntities; + break; + case RuntimeGenerationResetStage.CompleteCanonicalEntities: + if (!_entityObjects.CompleteSessionClearIfConverged()) + { + throw new InvalidOperationException( + "Canonical entity/object lifetime still owns an " + + "unacknowledged session retirement."); + } + state.Stage = + RuntimeGenerationResetStage.CompleteHostProjection; + break; + case RuntimeGenerationResetStage.CompleteHostProjection: + state.Host.CompleteEntityProjectionRetirement(); + state.Stage = RuntimeGenerationResetStage.ChatIdentity; + break; + case RuntimeGenerationResetStage.ChatIdentity: + Advance(state, _communication.ResetChatIdentity); + break; + case RuntimeGenerationResetStage.PlayerSnapshots: + Advance(state, _inventory.ResetPlayerSnapshots); + break; + case RuntimeGenerationResetStage.PlayerIdentity: + _identity.ResetSession(); + Complete(state); + break; + default: + throw new InvalidOperationException( + $"Unsupported Runtime generation reset stage {state.Stage}."); + } + } + } + + private void RetireCurrentEntity(ResetState state) + { + RuntimeEntityRecord[] retirements = state.Retirements ?? []; + if (state.RetirementCursor >= retirements.Length) + { + state.Stage = RuntimeGenerationResetStage.DrainHostProjection; + return; + } + + RuntimeEntityRecord current = retirements[state.RetirementCursor]; + if (!state.CurrentProjectionAcknowledged) + { + state.Host.RetireEntityProjection(current); + state.CurrentProjectionAcknowledged = true; + } + + _entityObjects.CompleteSessionEntityRetirement(current); + state.CurrentProjectionAcknowledged = false; + state.RetirementCursor++; + } + + private static void Advance(ResetState state, Action action) + { + action(); + state.Stage++; + } + + private void Complete(ResetState state) + { + state.Stage = RuntimeGenerationResetStage.Complete; + _lastCompletedGeneration = state.Generation; + _hasCompletedGeneration = true; + _state = null; + } + + private sealed class ResetState + { + public ResetState( + RuntimeGenerationToken generation, + IRuntimeGenerationResetHost host, + long transactionId) + { + Generation = generation; + Host = host; + TransactionId = transactionId; + Stage = RuntimeGenerationResetStage.Transit; + } + + public RuntimeGenerationToken Generation { get; } + public IRuntimeGenerationResetHost Host { get; } + public long TransactionId { get; } + public RuntimeGenerationResetStage Stage { get; set; } + public RuntimeEntityRecord[]? Retirements { get; set; } + public int RetirementCursor { get; set; } + public bool CurrentProjectionAcknowledged { get; set; } + + public static ResetState Completed( + RuntimeGenerationToken generation, + IRuntimeGenerationResetHost host) => + new(generation, host, 0) + { + Stage = RuntimeGenerationResetStage.Complete, + }; + } +} diff --git a/src/AcDream.Runtime/Session/LiveSessionController.cs b/src/AcDream.Runtime/Session/LiveSessionController.cs index d5292e76..04efe483 100644 --- a/src/AcDream.Runtime/Session/LiveSessionController.cs +++ b/src/AcDream.Runtime/Session/LiveSessionController.cs @@ -58,7 +58,7 @@ public sealed record LiveSessionStartResult( public interface ILiveSessionLifecycleHost { LiveSessionBinding BindSession(WorldSession session); - void ResetSessionState(); + void ResetSessionState(RuntimeGenerationToken retiringGeneration); void ReportConnecting(string host, int port, string user); void ReportConnected(); void ApplySelectedCharacter(LiveSessionCharacterSelection selection); @@ -185,12 +185,14 @@ public sealed class LiveSessionController { private sealed class SessionScope( WorldSession session, - ILiveSessionLifecycleHost host) + ILiveSessionLifecycleHost host, + RuntimeGenerationToken generation) { private int _teardownStage; public WorldSession Session { get; } = session; public ILiveSessionLifecycleHost Host { get; } = host; + public RuntimeGenerationToken Generation { get; } = generation; public LiveSessionBinding? Binding { get; set; } public bool HostAttached { get; set; } public RuntimeTeardownStage CompletedStages { get; private set; } @@ -226,7 +228,7 @@ public sealed class LiveSessionController } if (_teardownStage == 3) { - Host.ResetSessionState(); + Host.ResetSessionState(Generation); CompletedStages |= RuntimeTeardownStage.HostReset; _teardownStage = 4; } @@ -247,11 +249,15 @@ public sealed class LiveSessionController LiveSessionConnectOptions? Options = null, ILiveSessionLifecycleHost? Host = null); + private sealed record PendingHostReset( + ILiveSessionLifecycleHost Host, + RuntimeGenerationToken Generation); + private readonly object _gate = new(); private readonly ILiveSessionOperations _operations; private SessionScope? _scope; private SessionScope? _retiredScope; - private ILiveSessionLifecycleHost? _pendingInitialResetHost; + private PendingHostReset? _pendingInitialReset; private PendingOperation? _pendingOperation; private int _operationDepth; private bool _inWorld; @@ -306,7 +312,7 @@ public sealed class LiveSessionController _inWorld, _scope is not null, _retiredScope is not null, - _pendingInitialResetHost is not null, + _pendingInitialReset is not null, _pendingOperation is not null, _operationDepth, _generation, @@ -460,7 +466,9 @@ public sealed class LiveSessionController ILiveSessionLifecycleHost host) { ILiveSessionLifecycleHost? oldHost = - _scope?.Host ?? _retiredScope?.Host ?? _pendingInitialResetHost; + _scope?.Host + ?? _retiredScope?.Host + ?? _pendingInitialReset?.Host; ulong generationBeforeStop = _generation; try { @@ -480,6 +488,7 @@ public sealed class LiveSessionController ILiveSessionLifecycleHost host, bool resetHost) { + RuntimeGenerationToken resetGeneration = new(_generation); ulong generation = ++_generation; try { @@ -487,7 +496,12 @@ public sealed class LiveSessionController if (_generation != generation) return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); if (resetHost) - ResetHostBeforeStart(host, generation); + { + ResetHostBeforeStart( + host, + generation, + resetGeneration); + } if (_generation != generation) return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); } @@ -509,7 +523,10 @@ public sealed class LiveSessionController return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); Console.WriteLine($"live: connecting to {endpoint} as {options.User}"); WorldSession session = _operations.CreateSession(endpoint); - scope = new SessionScope(session, host); + scope = new SessionScope( + session, + host, + new RuntimeGenerationToken(generation)); _scope = scope; _inWorld = false; if (!IsCurrent(scope, generation)) @@ -614,7 +631,7 @@ public sealed class LiveSessionController } DrainRetiredScope(); DrainPendingInitialReset(); - if (_retiredScope is null && _pendingInitialResetHost is null) + if (_retiredScope is null && _pendingInitialReset is null) _lastTeardownStages = RuntimeTeardownStage.Complete; } @@ -632,35 +649,38 @@ public sealed class LiveSessionController private void ResetHostBeforeStart( ILiveSessionLifecycleHost host, - ulong generation) + ulong generation, + RuntimeGenerationToken resetGeneration) { bool requestedHostAlreadyReset = false; - if (_pendingInitialResetHost is { } pending) + if (_pendingInitialReset is { } pending) { - pending.ResetSessionState(); - _pendingInitialResetHost = null; - requestedHostAlreadyReset = ReferenceEquals(pending, host); + pending.Host.ResetSessionState(pending.Generation); + _pendingInitialReset = null; + requestedHostAlreadyReset = + ReferenceEquals(pending.Host, host); } if (requestedHostAlreadyReset || _generation != generation) return; try { - host.ResetSessionState(); + host.ResetSessionState(resetGeneration); } catch { - _pendingInitialResetHost = host; + _pendingInitialReset = + new PendingHostReset(host, resetGeneration); throw; } } private void DrainPendingInitialReset() { - if (_pendingInitialResetHost is not { } host) + if (_pendingInitialReset is not { } pending) return; - host.ResetSessionState(); - _pendingInitialResetHost = null; + pending.Host.ResetSessionState(pending.Generation); + _pendingInitialReset = null; } private void Schedule(PendingOperation operation) diff --git a/src/AcDream.Runtime/Session/LiveSessionHost.cs b/src/AcDream.Runtime/Session/LiveSessionHost.cs index 1a1a9def..049b8286 100644 --- a/src/AcDream.Runtime/Session/LiveSessionHost.cs +++ b/src/AcDream.Runtime/Session/LiveSessionHost.cs @@ -24,7 +24,7 @@ public sealed record LiveSessionEnteredWorldBindings( public sealed record LiveSessionHostBindings( LiveSessionRoutingFactories Routing, - Action Reset, + Action Reset, LiveSessionSelectionBindings Selection, LiveSessionEnteredWorldBindings EnteredWorld, Action Connecting, @@ -84,7 +84,7 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands private readonly LiveSessionRoutingFactories _routing; private readonly LiveSessionSelectionBindings _selection; private readonly LiveSessionEnteredWorldBindings _enteredWorld; - private readonly Action _reset; + private readonly Action _reset; private readonly LiveSessionLifecycleHost _lifecycle; private PendingRouteRollback? _pendingRouteRollback; @@ -188,13 +188,14 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands } } - private void ResetSessionState() + private void ResetSessionState( + RuntimeGenerationToken retiringGeneration) { // An incompletely detached route can still deliver callbacks into the // state below. Treat physical route convergence as the same hard // barrier used by normal LiveSessionBinding teardown. DrainPendingRouteRollback(); - _reset(); + _reset(retiringGeneration); } private void ApplySelection(LiveSessionCharacterSelection selection) diff --git a/src/AcDream.Runtime/Session/LiveSessionLifecycleHost.cs b/src/AcDream.Runtime/Session/LiveSessionLifecycleHost.cs index 2e34741e..b0134366 100644 --- a/src/AcDream.Runtime/Session/LiveSessionLifecycleHost.cs +++ b/src/AcDream.Runtime/Session/LiveSessionLifecycleHost.cs @@ -4,7 +4,7 @@ namespace AcDream.Runtime.Session; public sealed record LiveSessionLifecycleBindings( Func Bind, - Action Reset, + Action Reset, Action Connecting, Action Connected, Action Selected, @@ -42,7 +42,9 @@ public sealed class LiveSessionLifecycleHost : ILiveSessionLifecycleHost return binding; } - public void ResetSessionState() => _bindings.Reset(); + public void ResetSessionState( + RuntimeGenerationToken retiringGeneration) => + _bindings.Reset(retiringGeneration); public void ReportConnecting(string host, int port, string user) => _bindings.Connecting(host, port, user); diff --git a/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs b/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs index 6335b165..33f4c3a1 100644 --- a/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs +++ b/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs @@ -13,6 +13,7 @@ using AcDream.Core.Selection; using AcDream.Core.Social; using AcDream.Core.Spells; using AcDream.Core.World; +using AcDream.Runtime; using DatReaderWriter.DBObjs; namespace AcDream.App.Tests.Net; @@ -199,190 +200,107 @@ public sealed class LiveSessionResetPlanTests } [Fact] - public void Manifest_MutatedA_FailedDrain_RetryConvergesWithoutClearingRetainedWorld() + public void Manifest_PreservesHostOrderAndForwardsExactRetiringGeneration() { - const uint playerA = 0x50000001u; - const uint objectA = 0x70000001u; - const uint landblock = 0x0101FFFFu; - var staticEntity = Entity(42u, 0u); - var spatial = new GpuWorldState(); - spatial.AddLandblock(new LoadedLandblock( - landblock, - new LandBlock(), - new List { staticEntity })); - var live = LiveEntityRuntimeFixture.Create(spatial, new FailingOnceResources()); - WorldSession.EntitySpawn spawn = Spawn(playerA, 1, 1, 0x01010001u); - live.RegisterLiveEntity(spawn); - live.MaterializeLiveEntity( - playerA, - 0x01010001u, - id => Entity(id, playerA)); - spatial.MarkPersistent(playerA); - - var objects = new ClientObjectTable(); - objects.AddOrUpdate(new ClientObject { ObjectId = objectA, Name = "A" }); - var spells = new Spellbook(); - spells.OnSpellLearned(123u, 1f); - var combat = new CombatState(); - combat.OnUpdateHealth(objectA, 0.5f); - combat.SetCombatMode(CombatMode.Melee); - var mana = new ItemManaState(); - mana.OnQueryItemManaResponse(objectA, 0.5f, valid: true); - var localPlayer = new LocalPlayerState(spells); - localPlayer.OnVitalUpdate(7u, 1u, 100u, 5u, 80u); - var friends = new FriendsState(); - friends.Apply(new FriendsUpdate( - FriendsUpdateType.Full, - [new FriendEntry(objectA, "A", true, false, [], [])])); - var squelch = new SquelchState(); - squelch.Replace(new SquelchDatabase( - new Dictionary { ["A"] = 1u }, - new Dictionary(), - new SquelchInfo("A", false, new HashSet()))); - var turbine = new TurbineChatState(); - turbine.OnChannelsReceived(1u, 2u, 3u, 4u, 5u, 6u, 7u, 8u, 9u, 10u); - var selection = new SelectionState(); - selection.Select(objectA, SelectionChangeSource.World); - var external = new ExternalContainerState(); - external.RequestOpen(objectA); - external.ApplyViewContents(objectA); - var chat = new ChatLog(); - chat.SetLocalPlayerGuid(playerA); - chat.OnSystemMessage("retained transcript", 1u); - - uint sessionIdentity = playerA; - int savedDefaultSetting = 73; - int activeSetting = savedDefaultSetting; - var calls = new Dictionary(StringComparer.Ordinal); - var dirty = ExpectedManifestNames().ToDictionary( - static name => name, - static _ => true, - StringComparer.Ordinal); - - Action Stage(string name, Action? reset = null) => () => - { - calls[name] = calls.GetValueOrDefault(name) + 1; - reset?.Invoke(); - dirty[name] = false; - }; - - LiveSessionResetPlan plan = LiveSessionResetManifest.Create(new() + var calls = new List(); + RuntimeGenerationToken observed = default; + Action Stage(string name) => () => calls.Add(name); + var plan = LiveSessionResetManifest.Create(new() { MouseCapture = Stage("mouse capture"), PlayerPresentation = Stage("player presentation"), - TeleportTransit = Stage("teleport transit"), + TeleportPresentation = Stage("teleport presentation"), SessionDialogs = Stage("session dialogs"), - ChatCommandTargets = Stage("chat command targets"), - SettingsCharacterContext = Stage( - "settings character context", - () => activeSetting = savedDefaultSetting), + SettingsCharacterContext = Stage("settings character context"), EquippedChildren = Stage("equipped children"), - ExternalContainer = Stage("external container", () => external.Reset()), - InteractionAndSelection = Stage( - "interaction and selection", - () => selection.Reset()), - InventoryTransactions = Stage("inventory transactions"), + InteractionPresentation = Stage("interaction presentation"), SelectionPresentation = Stage("selection presentation"), - ObjectTable = Stage("object table", objects.Clear), - Spellbook = Stage("spellbook", spells.Clear), - MagicRuntime = Stage("magic runtime"), - CombatAttack = Stage("combat attack"), - CombatState = Stage("combat state", combat.Clear), - ItemMana = Stage("item mana", mana.Clear), - LocalPlayer = Stage("local player", localPlayer.Clear), - Friends = Stage("friends", friends.Clear), - Squelch = Stage("squelch", squelch.Clear), - TurbineChat = Stage("turbine chat", turbine.Reset), ParticleVisibility = Stage("particle visibility"), InboundEventFifo = Stage("inbound event fifo"), LiveLiveness = Stage("live liveness"), - LiveRuntime = Stage( - "live runtime", - () => LiveSessionEntityRuntimeReset.ClearAndRequireConvergence(live)), - RenderSceneProjection = Stage("render scene projection"), - SessionIdentity = Stage("session identity", () => + RuntimeGeneration = generation => { - LiveSessionEntityRuntimeReset.RequireConvergence(live); - sessionIdentity = 0u; - chat.ResetSessionIdentity(); - }), + observed = generation; + calls.Add("runtime generation"); + }, + SessionIdentityPresentation = _ => + calls.Add("session identity presentation"), RemoteTeleport = Stage("remote teleport"), NetworkEffects = Stage("network effects"), AnimationHookFrames = Stage("animation hook frames"), LivePresentation = Stage("live presentation"), - RemoteMovementDiagnostics = Stage("remote movement diagnostics"), + RemoteMovementDiagnostics = + Stage("remote movement diagnostics"), }); + var retiring = new RuntimeGenerationToken(73); + plan.Execute(retiring); + Assert.Equal(ExpectedManifestNames(), plan.StageNames); - AggregateException first = Assert.Throws(plan.Execute); + Assert.Equal(ExpectedManifestNames(), calls); + Assert.Equal(retiring, observed); + } + + [Fact] + public void GraphicalResetHost_RetriesExactProjectionBeforeIdentityClears() + { + const uint player = 0x50000001u; + const uint landblock = 0x0101FFFFu; + using GameRuntime runtime = GameRuntimeTestFactory.Create(); + runtime.PlayerIdentity.ServerGuid = player; + var spatial = new GpuWorldState(); + spatial.AddLandblock(new LoadedLandblock( + landblock, + new LandBlock(), + Array.Empty())); + var live = new LiveEntityRuntime( + spatial, + new FailingOnceResources(), + runtime.EntityObjects); + live.RegisterLiveEntity(Spawn(player, 1, 1, 0x01010001u)); + live.MaterializeLiveEntity( + player, + 0x01010001u, + id => Entity(id, player)); + var host = new GraphicalRuntimeGenerationResetHost( + live, + static () => { }); + var retiring = new RuntimeGenerationToken(3); + + RuntimeGenerationResetStageException first = + Assert.Throws( + () => runtime.ResetGeneration(retiring, host)); Assert.Equal( - ["live runtime", "session identity"], - first.InnerExceptions - .Cast() - .Select(static error => error.StageName)); - Assert.Equal(playerA, sessionIdentity); + RuntimeGenerationResetStage.RetireEntities, + first.Stage); + Assert.Equal(player, runtime.PlayerIdentity.ServerGuid); Assert.Equal(1, live.PendingTeardownCount); - Assert.Contains(staticEntity, spatial.Entities); - Assert.Equal(1, spatial.PersistentGuidCount); + Assert.Equal(1, live.MaterializedCount); - plan.Execute(); + runtime.ResetGeneration(retiring, host); - Assert.All(ExpectedManifestNames(), name => Assert.Equal(2, calls[name])); - Assert.DoesNotContain(dirty, static pair => pair.Value); - Assert.Equal(0u, sessionIdentity); - Assert.Equal(savedDefaultSetting, activeSetting); + Assert.Equal(0u, runtime.PlayerIdentity.ServerGuid); Assert.Equal(0, live.Count); Assert.Equal(0, live.PendingTeardownCount); Assert.Equal(0, live.MaterializedCount); - Assert.Equal(0, spatial.PersistentGuidCount); - Assert.Contains(staticEntity, spatial.Entities); - Assert.True(spatial.IsLoaded(landblock)); - Assert.Null(objects.Get(objectA)); - Assert.Equal(0, spells.LearnedCount); - Assert.Equal(CombatMode.NonCombat, combat.CurrentMode); - Assert.False(combat.HasHealth(objectA)); - Assert.False(mana.HasMana(objectA)); - Assert.Null(localPlayer.Get(LocalPlayerState.VitalKind.Health)); - Assert.Empty(friends.Snapshot()); - Assert.Same(SquelchDatabase.Empty, squelch.Snapshot()); - Assert.False(turbine.Enabled); - Assert.Null(selection.SelectedObjectId); - Assert.Equal(0u, external.CurrentContainerId); - Assert.Equal(1, chat.Count); - chat.OnLocalSpeech("A", "after", playerA, isRanged: false); - Assert.Equal("A", chat.Snapshot()[1].Sender); } private static string[] ExpectedManifestNames() => [ "mouse capture", "player presentation", - "teleport transit", + "teleport presentation", "session dialogs", - "chat command targets", "settings character context", "equipped children", - "external container", - "interaction and selection", - "inventory transactions", + "interaction presentation", "selection presentation", - "object table", - "spellbook", - "magic runtime", - "combat attack", - "combat state", - "item mana", - "local player", - "friends", - "squelch", - "turbine chat", "particle visibility", "inbound event fifo", "live liveness", - "live runtime", - "render scene projection", - "session identity", + "runtime generation", + "session identity presentation", "remote teleport", "network effects", "animation hook frames", diff --git a/tests/AcDream.App.Tests/Net/LiveSessionShutdownIntegrationTests.cs b/tests/AcDream.App.Tests/Net/LiveSessionShutdownIntegrationTests.cs index 408c019b..50852c52 100644 --- a/tests/AcDream.App.Tests/Net/LiveSessionShutdownIntegrationTests.cs +++ b/tests/AcDream.App.Tests/Net/LiveSessionShutdownIntegrationTests.cs @@ -2,6 +2,7 @@ using System.Net; using AcDream.App.Rendering; using AcDream.Core.Net; using AcDream.Core.Net.Messages; +using AcDream.Runtime; using AcDream.Runtime.Session; namespace AcDream.App.Tests.Net; @@ -92,7 +93,8 @@ public sealed class LiveSessionShutdownIntegrationTests deactivateCommands: static () => { }, detachEvents: static () => { }); - public void ResetSessionState() { } + public void ResetSessionState( + RuntimeGenerationToken retiringGeneration) { } public void ReportConnecting(string host, int port, string user) { } public void ReportConnected() { } public void ApplySelectedCharacter(LiveSessionCharacterSelection selection) { } diff --git a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs index 87025d8c..476bcf3a 100644 --- a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs +++ b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs @@ -945,31 +945,17 @@ public sealed class CurrentGameRuntimeAdapterTests { MouseCapture = noop, PlayerPresentation = noop, - TeleportTransit = noop, + TeleportPresentation = noop, SessionDialogs = noop, - ChatCommandTargets = noop, SettingsCharacterContext = noop, EquippedChildren = noop, - ExternalContainer = noop, - InteractionAndSelection = noop, - InventoryTransactions = noop, + InteractionPresentation = noop, SelectionPresentation = noop, - ObjectTable = noop, - Spellbook = noop, - MagicRuntime = noop, - CombatAttack = noop, - CombatState = noop, - ItemMana = noop, - LocalPlayer = noop, - Friends = noop, - Squelch = noop, - TurbineChat = noop, ParticleVisibility = noop, InboundEventFifo = noop, LiveLiveness = noop, - LiveRuntime = noop, - RenderSceneProjection = noop, - SessionIdentity = noop, + RuntimeGeneration = _ => { }, + SessionIdentityPresentation = _ => { }, RemoteTeleport = noop, NetworkEffects = noop, AnimationHookFrames = noop, diff --git a/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs b/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs index 8cf8aefd..1b6c9fcc 100644 --- a/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs @@ -850,6 +850,9 @@ public sealed class LocalPlayerTeleportControllerTests public void ResetSession() { } + public void ResetGenerationPresentation() + { + } } private sealed class FakePresentation : ILocalPlayerTeleportPresentation diff --git a/tests/AcDream.Runtime.Tests/NoWindowGameRuntimeHostTests.cs b/tests/AcDream.Runtime.Tests/NoWindowGameRuntimeHostTests.cs new file mode 100644 index 00000000..e087e0ee --- /dev/null +++ b/tests/AcDream.Runtime.Tests/NoWindowGameRuntimeHostTests.cs @@ -0,0 +1,650 @@ +using System.Numerics; +using AcDream.Core.Items; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Runtime.Entities; +using AcDream.Runtime.Tests.Support; +using AcDream.Runtime.World; + +namespace AcDream.Runtime.Tests; + +public sealed class NoWindowGameRuntimeHostTests +{ + [Fact] + public void RootHostRunsPopulatedGenerationWithoutPresentationAssembly() + { + var host = new NoWindowGameRuntimeHost(); + + RuntimeSessionStartResult started = host.Start(); + Assert.Equal(RuntimeSessionStartStatus.Connected, started.Status); + Assert.Equal(0x50000001u, host.Runtime.PlayerIdentity.ServerGuid); + Assert.True(host.Deliver(runtime => + { + runtime.EntityObjects.RegisterEntity( + Spawn(0x70000001u, 1)); + runtime.EntityObjects.Objects.AddOrUpdate(new ClientObject + { + ObjectId = 0x80000001u, + Name = "fixture", + ContainerId = runtime.PlayerIdentity.ServerGuid, + }); + runtime.CommunicationOwner.Chat.OnSystemMessage( + "fixture", + 1u); + })); + host.Advance(1d / 30d); + RuntimeStateCheckpoint populated = + host.Runtime.CaptureCheckpoint(); + Assert.Equal(1, populated.EntityCount); + Assert.Equal(1, populated.InventoryObjectCount); + Assert.Equal(1, populated.ChatCount); + + RuntimeTeardownAcknowledgement stopped = host.Stop(); + + Assert.True(stopped.IsComplete); + Assert.Equal(0u, host.Runtime.PlayerIdentity.ServerGuid); + Assert.Equal(0, host.Runtime.Entities.Count); + Assert.Equal(0, host.Runtime.Inventory.ObjectCount); + Assert.False(host.Deliver(_ => + throw new InvalidOperationException("old route leaked"))); + Assert.False(host.Execute(_ => + throw new InvalidOperationException("old command leaked"))); + Assert.Equal(1, host.ProjectionRetirementCount); + Assert.Equal(2, host.ProjectionDrainCount); + Assert.Equal(2, host.ProjectionCompletionCount); + + string[] loaded = AppDomain.CurrentDomain.GetAssemblies() + .Select(static assembly => + assembly.GetName().Name ?? string.Empty) + .ToArray(); + Assert.DoesNotContain(loaded, static name => + name.StartsWith( + "AcDream.App", + StringComparison.OrdinalIgnoreCase) + || name.StartsWith( + "AcDream.UI.", + StringComparison.OrdinalIgnoreCase) + || name.StartsWith( + "Silk.NET", + StringComparison.OrdinalIgnoreCase) + || name.StartsWith( + "OpenAL", + StringComparison.OrdinalIgnoreCase) + || name.StartsWith( + "Arch", + StringComparison.OrdinalIgnoreCase) + || name.StartsWith( + "ImGui", + StringComparison.OrdinalIgnoreCase)); + + host.Dispose(); + Assert.True(host.Runtime.CaptureOwnership().IsConverged); + } + + [Fact] + public void ReconnectReusesRootButNotPriorGuidIncarnationOrRoutes() + { + const uint guid = 0x70000001u; + var host = new NoWindowGameRuntimeHost(); + RuntimeSessionStartResult first = host.Start(); + RuntimeGenerationToken firstGeneration = first.Generation; + Assert.True(host.Deliver(runtime => + runtime.EntityObjects.RegisterEntity(Spawn(guid, 1)))); + Assert.True(host.Runtime.EntityObjects.Entities.TryGetActive( + guid, + out RuntimeEntityRecord firstRecord)); + + RuntimeSessionStartResult second = host.Reconnect(); + RuntimeGenerationToken secondGeneration = second.Generation; + + Assert.Equal(RuntimeSessionStartStatus.Connected, second.Status); + Assert.NotEqual(firstGeneration, secondGeneration); + Assert.False(host.Runtime.EntityObjects.Entities.TryGetActive( + guid, + out _)); + Assert.True(host.Deliver(runtime => + runtime.EntityObjects.RegisterEntity(Spawn(guid, 2)))); + Assert.True(host.Runtime.EntityObjects.Entities.TryGetActive( + guid, + out RuntimeEntityRecord secondRecord)); + Assert.NotSame(firstRecord, secondRecord); + Assert.Equal((ushort)2, secondRecord.Incarnation); + Assert.Equal(0, host.Runtime.EntityObjects.Entities + .PendingTeardownCount); + Assert.Contains( + $"events-:{firstGeneration.Value}", + host.LifecycleTrace); + Assert.Contains( + $"commands-:{firstGeneration.Value}", + host.LifecycleTrace); + + host.Dispose(); + Assert.True(host.Runtime.CaptureOwnership().IsConverged); + } + + [Fact] + public void DeterministicLifecycleUsesOneRootAcrossGameplayPortalAndReconnect() + { + const uint localGuid = 0x50000001u; + const uint reusedGuid = 0x70000001u; + const uint usableGuid = 0x70000002u; + const uint containerGuid = 0x80000001u; + const uint itemGuid = 0x80000002u; + const string passwordMarker = "credential-must-not-appear"; + var host = new NoWindowGameRuntimeHost( + password: passwordMarker); + + RuntimeStateCheckpoint empty = host.CaptureCheckpoint(); + Assert.Equal(RuntimeLifecycleState.Constructed, empty.Lifecycle); + Assert.Equal(0, empty.EntityCount); + Assert.Equal(0, empty.InventoryObjectCount); + Assert.Equal(0, empty.ChatCount); + + RuntimeSessionStartResult first = host.Start(); + RuntimeGenerationToken firstGeneration = first.Generation; + Assert.Equal(RuntimeSessionStartStatus.Connected, first.Status); + Assert.True(host.Deliver(firstGeneration, runtime => + { + runtime.EntityObjects.RegisterEntity(Spawn(localGuid, 1)); + runtime.EntityObjects.RegisterEntity(Spawn(reusedGuid, 1)); + runtime.EntityObjects.RegisterEntity(Spawn(usableGuid, 1)); + runtime.EntityObjects.Objects.AddOrUpdate(new ClientObject + { + ObjectId = containerGuid, + Name = "fixture pack", + ContainerId = localGuid, + }); + runtime.EntityObjects.Objects.AddContainer(new Container + { + ObjectId = containerGuid, + Capacity = 24, + }); + runtime.EntityObjects.Objects.AddOrUpdate(new ClientObject + { + ObjectId = itemGuid, + Name = "fixture item", + ContainerId = localGuid, + StackSize = 3, + StackSizeMax = 10, + }); + Assert.True(runtime.EntityObjects.Objects.ApplyServerMove( + itemGuid, + containerGuid, + newWielderId: 0u, + newSlot: 0)); + runtime.CommunicationOwner.Chat.OnSystemMessage( + "fixture system", + 1u); + runtime.CommunicationOwner.Chat.OnLocalSpeech( + "Runtime", + "fixture speech", + localGuid, + isRanged: false); + runtime.CharacterOwner.LocalPlayer.OnVitalUpdate( + vitalId: 1u, + ranks: 100u, + start: 100u, + xp: 50u, + current: 95u); + runtime.EnvironmentOwner.Initialize( + new RuntimeWorldEnvironmentDefinition( + originOffsetTicks: 0d, + sourceTickSize: 1d, + lightTickSize: 1d, + dayGroups: null)); + runtime.EnvironmentOwner.SynchronizeFromServer(1234d); + })); + + Assert.True(host.Move()); + Assert.True(host.Use(usableGuid)); + Assert.True(host.Attack()); + Assert.True(host.CastFixtureSpell()); + Assert.True(host.AdvanceProjectile()); + Assert.True(host.CompletePortal()); + host.Advance(1d / 30d); + + RuntimeStateCheckpoint populated = host.CaptureCheckpoint(); + Assert.Equal(RuntimeLifecycleState.InWorld, populated.Lifecycle); + Assert.Equal(4, populated.EntityCount); + Assert.Equal(2, populated.InventoryObjectCount); + Assert.Equal(1, populated.InventoryContainerCount); + Assert.Equal(2, populated.ChatCount); + Assert.Equal(1, populated.Character.LearnedSpellCount); + Assert.True(populated.Movement.AutoRunActive); + Assert.True(populated.Portal.Completed); + Assert.True(populated.Portal.WorldViewportObserved); + Assert.Equal(0, populated.TransitOwnership.HostProjectionCount); + + Assert.Equal( + [ + "movement:run", + $"use:{usableGuid:X8}", + "attack:prepare", + "attack:Medium:0.5", + "cast:stop", + "cast:1", + "cast:busy", + "projectile:ack", + ], + host.GameplayTrace); + RuntimeTraceEntry[] firstTrace = host.Trace.Entries + .Where(entry => + entry.Stamp.Generation == firstGeneration) + .ToArray(); + Assert.Contains( + firstTrace, + static entry => entry.Kind == RuntimeTraceKind.Lifecycle); + Assert.Contains( + firstTrace, + static entry => entry.Kind == RuntimeTraceKind.Inventory + && entry.Code == (int)RuntimeInventoryChange.Moved); + Assert.Contains( + firstTrace, + static entry => entry.Kind == RuntimeTraceKind.Movement); + Assert.Contains( + firstTrace, + static entry => entry.Kind == RuntimeTraceKind.Command + && entry.Code >> 16 + == (int)RuntimeCommandDomain.Magic); + Assert.Equal( + 5, + firstTrace.Count(static entry => + entry.Kind == RuntimeTraceKind.Portal)); + AssertStrictlyOrdered(firstTrace); + + Assert.True(host.Runtime.EntityObjects.Entities.TryGetActive( + reusedGuid, + out RuntimeEntityRecord firstRecord)); + RuntimeSessionStartResult second = host.Reconnect(); + RuntimeGenerationToken secondGeneration = second.Generation; + Assert.Equal(RuntimeSessionStartStatus.Connected, second.Status); + Assert.NotEqual(firstGeneration, secondGeneration); + Assert.False(host.Deliver( + firstGeneration, + _ => throw new InvalidOperationException( + "retired inbound route ran"))); + Assert.False(host.Execute( + firstGeneration, + _ => throw new InvalidOperationException( + "retired command route ran"))); + + RuntimeStateCheckpoint reset = host.Runtime.CaptureCheckpoint(); + Assert.Equal(0, reset.EntityCount); + Assert.Equal(0, reset.InventoryObjectCount); + // Retail's end-character path resets active communication targets and + // identity but does not issue ClearChatBuffer; bounded chat history is + // therefore retained by this reusable root. + Assert.Equal(2, reset.ChatCount); + Assert.Equal(0, reset.Character.LearnedSpellCount); + Assert.Equal(0u, reset.Actions.SelectedObjectId); + Assert.Equal(RuntimePortalSnapshot.Idle, reset.Portal); + Assert.False(reset.Movement.AutoRunActive); + Assert.True(reset.EnvironmentOwnership.IsInitialized); + + Assert.True(host.Deliver(secondGeneration, runtime => + runtime.EntityObjects.RegisterEntity( + Spawn(reusedGuid, 2)))); + Assert.True(host.Runtime.EntityObjects.Entities.TryGetActive( + reusedGuid, + out RuntimeEntityRecord secondRecord)); + Assert.NotSame(firstRecord, secondRecord); + Assert.Equal((ushort)2, secondRecord.Incarnation); + RuntimeStateCheckpoint secondCheckpoint = + host.CaptureCheckpoint(); + Assert.Equal(1, secondCheckpoint.EntityCount); + Assert.Equal(0, secondCheckpoint.InventoryObjectCount); + Assert.Equal(2, secondCheckpoint.ChatCount); + + Assert.True(host.Stop().IsComplete); + AssertStrictlyOrdered( + host.Trace.Entries + .Where(entry => + entry.Stamp.Generation == secondGeneration) + .ToArray()); + Assert.DoesNotContain( + host.LifecycleTrace.Concat(host.GameplayTrace), + line => line.Contains( + passwordMarker, + StringComparison.Ordinal)); + + host.Dispose(); + Assert.True(host.Runtime.CaptureOwnership().IsConverged); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void PendingPortalCancellationRetiresHostAcknowledgementsBeforeReconnect( + bool destinationReady) + { + var host = new NoWindowGameRuntimeHost(); + Assert.Equal( + RuntimeSessionStartStatus.Connected, + host.Start().Status); + RuntimeWorldTransitState transit = host.Runtime.TransitOwner; + const uint destinationCell = 0x8A020164u; + const ushort sequence = 7; + + Assert.True(transit.TryQueueTeleportStart(sequence)); + Assert.True(transit.ActivateQueuedTeleport()); + Assert.True(transit.OfferTeleportDestination( + new RuntimeTeleportDestination( + host.Runtime.PlayerIdentity.ServerGuid, + InstanceSequence: 1, + PositionSequence: 1, + TeleportSequence: sequence, + ForcePositionSequence: 1, + Position: new Position( + destinationCell, + Vector3.Zero, + Quaternion.Identity)), + teleportTimestampAdvanced: true)); + Assert.True(transit.TryBeginPortalReveal( + sequence, + destinationCell, + out long revealGeneration)); + Assert.True(transit.TryRegisterHostProjection( + revealGeneration, + destinationCell, + out RuntimeWorldHostProjectionToken projection)); + Assert.True(transit.AcknowledgeHostProjection( + new RuntimeWorldHostAcknowledgement( + projection, + RuntimeWorldHostAcknowledgementStage + .ProjectionRegistered))); + if (destinationReady) + { + Assert.True(transit.AcknowledgeDestinationReadiness( + new RuntimeDestinationReadiness( + revealGeneration, + destinationCell, + IsIndoor: true, + IsUnhydratable: false, + RequiredRenderRadius: 0, + IsRenderNeighborhoodReady: true, + AreCompositeTexturesReady: true, + IsCollisionReady: true))); + } + + Assert.True(transit.Cancel(revealGeneration)); + DrainPendingPortal(transit, projection); + Assert.Equal(0, transit.Ownership.HostProjectionCount); + + RuntimeSessionStartResult reconnected = host.Reconnect(); + + Assert.Equal( + RuntimeSessionStartStatus.Connected, + reconnected.Status); + Assert.Equal(RuntimePortalSnapshot.Idle, transit.Snapshot); + host.Dispose(); + Assert.True(host.Runtime.CaptureOwnership().IsConverged); + } + + [Fact] + public void ConcurrentRootsKeepCredentialsClockStateAndFailuresIsolated() + { + const string firstPassword = "first-password-marker"; + const string secondPassword = "second-password-marker"; + const uint sharedGuid = 0x70000055u; + var first = new NoWindowGameRuntimeHost( + user: "first-user", + password: firstPassword, + characterId: 0x50000011u, + characterName: "First"); + var second = new NoWindowGameRuntimeHost( + user: "second-user", + password: secondPassword, + characterId: 0x50000022u, + characterName: "Second"); + + Assert.Equal( + RuntimeSessionStartStatus.Connected, + first.Start().Status); + Assert.Equal( + RuntimeSessionStartStatus.Connected, + second.Start().Status); + Assert.True(first.Deliver(runtime => + { + runtime.EntityObjects.RegisterEntity(Spawn(sharedGuid, 1)); + runtime.EntityObjects.Objects.AddOrUpdate(new ClientObject + { + ObjectId = 0x80000011u, + Name = "first item", + ContainerId = runtime.PlayerIdentity.ServerGuid, + }); + runtime.CommunicationOwner.Chat.OnSystemMessage( + "first chat", + 1u); + })); + Assert.True(second.Deliver(runtime => + { + runtime.EntityObjects.RegisterEntity(Spawn(sharedGuid, 1)); + runtime.EntityObjects.Objects.AddOrUpdate(new ClientObject + { + ObjectId = 0x80000022u, + Name = "second item", + ContainerId = runtime.PlayerIdentity.ServerGuid, + }); + runtime.CommunicationOwner.Chat.OnSystemMessage( + "second chat", + 1u); + })); + Assert.True(first.CastFixtureSpell()); + Assert.True(second.CastFixtureSpell()); + Assert.True(first.AdvanceProjectile(0x70000011u)); + Assert.True(second.AdvanceProjectile(0x70000022u)); + Assert.True(first.CompletePortal(0x8A020164u, sequence: 1)); + Assert.True(second.CompletePortal(0x11340021u, sequence: 2)); + first.Advance(0.1d); + first.Advance(0.2d); + second.Advance(0.4d); + + Assert.True(first.Runtime.EntityObjects.Entities.TryGetActive( + sharedGuid, + out RuntimeEntityRecord firstRecord)); + Assert.True(second.Runtime.EntityObjects.Entities.TryGetActive( + sharedGuid, + out RuntimeEntityRecord secondRecord)); + Assert.NotSame(firstRecord, secondRecord); + Assert.Null(first.Runtime.EntityObjects.Objects.Get(0x80000022u)); + Assert.Null(second.Runtime.EntityObjects.Objects.Get(0x80000011u)); + Assert.Equal( + "first chat", + Assert.Single( + first.Runtime.CommunicationOwner.Chat.Snapshot()).Text); + Assert.Equal( + "second chat", + Assert.Single( + second.Runtime.CommunicationOwner.Chat.Snapshot()).Text); + Assert.Equal(2UL, first.Runtime.Clock.FrameNumber); + Assert.Equal(1UL, second.Runtime.Clock.FrameNumber); + Assert.NotEqual( + first.Runtime.Clock.SimulationTimeSeconds, + second.Runtime.Clock.SimulationTimeSeconds); + Assert.Equal( + 0x8A020164u, + first.Runtime.TransitOwner.Snapshot.DestinationCell); + Assert.Equal( + 0x11340021u, + second.Runtime.TransitOwner.Snapshot.DestinationCell); + Assert.DoesNotContain( + first.LifecycleTrace.Concat(first.GameplayTrace), + line => line.Contains( + firstPassword, + StringComparison.Ordinal) + || line.Contains( + secondPassword, + StringComparison.Ordinal)); + Assert.DoesNotContain( + second.LifecycleTrace.Concat(second.GameplayTrace), + line => line.Contains( + firstPassword, + StringComparison.Ordinal) + || line.Contains( + secondPassword, + StringComparison.Ordinal)); + + first.Dispose(); + second.Dispose(); + Assert.True(first.Runtime.CaptureOwnership().IsConverged); + Assert.True(second.Runtime.CaptureOwnership().IsConverged); + } + + [Fact] + public void ThrowingRootObserverIsContainedAndDoesNotBlockTeardown() + { + var host = new NoWindowGameRuntimeHost(); + using IDisposable failure = + host.Runtime.Subscribe(new ThrowingObserver()); + Assert.Equal( + RuntimeSessionStartStatus.Connected, + host.Start().Status); + + Assert.True(host.Deliver(runtime => + { + runtime.EntityObjects.RegisterEntity( + Spawn(0x70000077u, 1)); + runtime.CommunicationOwner.Chat.OnSystemMessage( + "observer fixture", + 1u); + })); + Assert.True(host.Move()); + Assert.True(host.CompletePortal()); + Assert.True( + host.Runtime.CaptureOwnership() + .Events.DispatchFailureCount >= 4); + + Assert.True(host.Stop().IsComplete); + failure.Dispose(); + host.Dispose(); + Assert.True(host.Runtime.CaptureOwnership().IsConverged); + } + + private static void AssertStrictlyOrdered( + IReadOnlyList entries) + { + for (int index = 1; index < entries.Count; index++) + { + Assert.True( + entries[index].Stamp.Sequence + > entries[index - 1].Stamp.Sequence, + $"trace sequence {entries[index].Stamp.Sequence} " + + $"did not follow {entries[index - 1].Stamp.Sequence}"); + } + } + + private static void DrainPendingPortal( + RuntimeWorldTransitState transit, + RuntimeWorldHostProjectionToken projection) + { + while (transit.TryGetHostProjection( + projection, + out RuntimeWorldHostProjectionSnapshot pending)) + { + RuntimeWorldHostAcknowledgementStage stages = + pending.PendingAcknowledgements; + RuntimeWorldHostAcknowledgementStage stage = + (stages & RuntimeWorldHostAcknowledgementStage + .SimulationReleaseProjected) != 0 + ? RuntimeWorldHostAcknowledgementStage + .SimulationReleaseProjected + : (stages & RuntimeWorldHostAcknowledgementStage + .DestinationReservationReleased) != 0 + ? RuntimeWorldHostAcknowledgementStage + .DestinationReservationReleased + : RuntimeWorldHostAcknowledgementStage + .TerminalProjected; + Assert.NotEqual( + RuntimeWorldHostAcknowledgementStage.None, + stages); + Assert.True(transit.AcknowledgeHostProjection( + new RuntimeWorldHostAcknowledgement( + projection, + stage))); + } + } + + private sealed class ThrowingObserver : IRuntimeEventObserver + { + public void OnLifecycle(in RuntimeLifecycleDelta delta) => + throw new InvalidOperationException("observer"); + public void OnCommand(in RuntimeCommandDelta delta) => + throw new InvalidOperationException("observer"); + public void OnEntity(in RuntimeEntityDelta delta) => + throw new InvalidOperationException("observer"); + public void OnInventory(in RuntimeInventoryDelta delta) => + throw new InvalidOperationException("observer"); + public void OnChat(in RuntimeChatDelta delta) => + throw new InvalidOperationException("observer"); + public void OnMovement(in RuntimeMovementDelta delta) => + throw new InvalidOperationException("observer"); + public void OnPortal(in RuntimePortalDelta delta) => + throw new InvalidOperationException("observer"); + } + + private static WorldSession.EntitySpawn Spawn( + uint guid, + ushort incarnation) + { + var position = new CreateObject.ServerPosition( + 0x01010001u, + 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: incarnation); + var physics = new PhysicsSpawnData( + RawState: (uint)PhysicsStateFlags.ReportCollisions, + Position: position, + Movement: null, + AnimationFrame: null, + SetupTableId: 0x02000001u, + MotionTableId: null, + 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, + guid.ToString("X8"), + null, + null, + null, + PhysicsState: physics.RawState, + InstanceSequence: incarnation, + MovementSequence: 1, + ServerControlSequence: 1, + PositionSequence: 1, + Physics: physics); + } +} diff --git a/tests/AcDream.Runtime.Tests/RuntimeGenerationResetTests.cs b/tests/AcDream.Runtime.Tests/RuntimeGenerationResetTests.cs new file mode 100644 index 00000000..1b6c8d00 --- /dev/null +++ b/tests/AcDream.Runtime.Tests/RuntimeGenerationResetTests.cs @@ -0,0 +1,359 @@ +using AcDream.Core.Combat; +using AcDream.Core.Items; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Core.Selection; +using AcDream.Core.Spells; +using AcDream.Runtime.Entities; +using AcDream.Runtime.Gameplay; + +namespace AcDream.Runtime.Tests; + +public sealed class RuntimeGenerationResetTests +{ + [Fact] + public void PopulatedResetConvergesEveryCanonicalOwnerAndStampsRetiringGeneration() + { + using var runtime = Create(); + const uint player = 0x50000001u; + const uint creature = 0x70000001u; + const uint item = 0x80000001u; + runtime.PlayerIdentity.ServerGuid = player; + runtime.EntityObjects.RegisterEntity(Spawn(creature, 3)); + runtime.EntityObjects.Objects.AddOrUpdate(new ClientObject + { + ObjectId = item, + Name = "item", + ContainerId = player, + }); + runtime.InventoryOwner.ExternalContainers.RequestOpen(creature); + runtime.InventoryOwner.ExternalContainers.ApplyViewContents(creature); + runtime.InventoryOwner.ItemMana.OnQueryItemManaResponse( + item, + 0.5f, + valid: true); + runtime.CharacterOwner.Spellbook.OnSpellLearned(123u, 1f); + runtime.CharacterOwner.LocalPlayer.OnVitalUpdate( + 7u, + 1u, + 100u, + 5u, + 80u); + runtime.ActionOwner.Selection.Select( + creature, + SelectionChangeSource.System); + runtime.ActionOwner.Combat.SetCombatMode(CombatMode.Missile); + runtime.CommunicationOwner.Chat.SetLocalPlayerGuid(player); + runtime.CommunicationOwner.Chat.OnSystemMessage("retained", 1u); + _ = runtime.MovementOwner.Execute( + RuntimeMovementCommand.ToggleRunLock); + var observer = new RecordingObserver(); + using IDisposable subscription = runtime.Subscribe(observer); + var host = new RecordingResetHost(runtime); + var retiring = new RuntimeGenerationToken(9); + + runtime.ResetGeneration(retiring, host); + + Assert.Single(host.Retired); + Assert.Equal(creature, host.Retired[0].ServerGuid); + Assert.Equal(1, host.DrainCalls); + Assert.Equal(1, host.CompleteCalls); + Assert.All( + observer.Entity.Concat(observer.Inventory), + stamp => Assert.Equal(retiring, stamp.Generation)); + Assert.Equal( + [1UL, 2UL], + observer.Inventory + .Concat(observer.Entity) + .OrderBy(static stamp => stamp.Sequence) + .Select(static stamp => stamp.Sequence)); + Assert.Equal(0u, runtime.PlayerIdentity.ServerGuid); + Assert.Equal(0, runtime.Entities.Count); + Assert.Equal(0, runtime.Inventory.ObjectCount); + Assert.Equal(0, runtime.CharacterOwner.Spellbook.LearnedCount); + Assert.Null(runtime.CharacterOwner.LocalPlayer.Get( + AcDream.Core.Player.LocalPlayerState.VitalKind.Health)); + Assert.Equal(0u, runtime.Actions.Snapshot.SelectedObjectId); + Assert.Equal(CombatMode.NonCombat, runtime.Actions.Snapshot.CombatMode); + Assert.False(runtime.MovementOwner.AutoRunActive); + Assert.Equal(1, runtime.CommunicationOwner.Chat.Count); + Assert.Null( + runtime.CommunicationOwner.CommandTargets.LastIncomingTellSender); + Assert.False(runtime.GenerationReset.CaptureSnapshot().IsActive); + + runtime.ResetGeneration(retiring, host); + + Assert.Single(host.Retired); + Assert.Equal(1, host.DrainCalls); + Assert.Equal(1, host.CompleteCalls); + } + + [Fact] + public void FailedHostDrainRetriesOnlyTheExactUnfinishedSuffix() + { + using var runtime = Create(); + runtime.PlayerIdentity.ServerGuid = 0x50000001u; + runtime.EntityObjects.RegisterEntity(Spawn(0x70000001u, 1)); + runtime.EntityObjects.RegisterEntity(Spawn(0x70000002u, 2)); + var host = new RecordingResetHost(runtime) + { + FailDrainOnce = true, + }; + var retiring = new RuntimeGenerationToken(12); + + RuntimeGenerationResetStageException failure = + Assert.Throws( + () => runtime.ResetGeneration(retiring, host)); + + Assert.Equal( + RuntimeGenerationResetStage.DrainHostProjection, + failure.Stage); + Assert.Equal(2, host.Retired.Count); + Assert.Equal(1, host.DrainCalls); + Assert.Equal(0, host.CompleteCalls); + Assert.Equal(0, runtime.EntityObjects.Entities.PendingTeardownCount); + Assert.NotEqual(0u, runtime.PlayerIdentity.ServerGuid); + RuntimeGenerationResetSnapshot pending = + runtime.GenerationReset.CaptureSnapshot(); + Assert.True(pending.IsActive); + Assert.Equal(2, pending.RetirementCursor); + + runtime.ResetGeneration(retiring, host); + + Assert.Equal(2, host.Retired.Count); + Assert.Equal(2, host.DrainCalls); + Assert.Equal(1, host.CompleteCalls); + Assert.Equal(0u, runtime.PlayerIdentity.ServerGuid); + Assert.False(runtime.GenerationReset.CaptureSnapshot().IsActive); + } + + [Fact] + public void FailedHostCompletionKeepsOldIdentityAndDoesNotReplayDrainOrEntities() + { + using var runtime = Create(); + const uint player = 0x50000001u; + runtime.PlayerIdentity.ServerGuid = player; + runtime.EntityObjects.RegisterEntity(Spawn(0x70000001u, 1)); + var host = new RecordingResetHost(runtime) + { + FailCompleteOnce = true, + }; + var retiring = new RuntimeGenerationToken(17); + + RuntimeGenerationResetStageException failure = + Assert.Throws( + () => runtime.ResetGeneration(retiring, host)); + + Assert.Equal( + RuntimeGenerationResetStage.CompleteHostProjection, + failure.Stage); + Assert.Equal(player, runtime.PlayerIdentity.ServerGuid); + Assert.Equal(0, runtime.EntityObjects.Entities.Count); + Assert.Equal(0, runtime.EntityObjects.Entities.PendingTeardownCount); + Assert.Single(host.Retired); + Assert.Equal(1, host.DrainCalls); + Assert.Equal(1, host.CompleteCalls); + + runtime.ResetGeneration(retiring, host); + + Assert.Single(host.Retired); + Assert.Equal(1, host.DrainCalls); + Assert.Equal(2, host.CompleteCalls); + Assert.Equal(0u, runtime.PlayerIdentity.ServerGuid); + } + + [Fact] + public void PendingResetRejectsHostReplacementAndReentrantReset() + { + using var runtime = Create(); + runtime.PlayerIdentity.ServerGuid = 0x50000001u; + runtime.EntityObjects.RegisterEntity(Spawn(0x70000001u, 1)); + var retiring = new RuntimeGenerationToken(4); + var host = new RecordingResetHost(runtime) + { + Reenter = true, + FailDrainOnce = true, + }; + + Assert.Throws( + () => runtime.ResetGeneration(retiring, host)); + Assert.IsType(host.ReentrantFailure); + Assert.Throws(() => + runtime.ResetGeneration( + retiring, + new RecordingResetHost(runtime))); + + runtime.ResetGeneration(retiring, host); + Assert.False(runtime.GenerationReset.CaptureSnapshot().IsActive); + } + + private static GameRuntime Create() + { + var operations = new Operations(); + return new GameRuntime(new GameRuntimeDependencies( + operations, + operations, + operations, + operations)); + } + + private static WorldSession.EntitySpawn Spawn( + uint guid, + ushort incarnation) + { + var position = new CreateObject.ServerPosition( + 0x01010001u, + 10f, + 10f, + 5f, + 1f, + 0f, + 0f, + 0f); + var timestamps = new PhysicsTimestamps( + 1, + 1, + 1, + 1, + 0, + 1, + 0, + 1, + incarnation); + var physics = new PhysicsSpawnData( + RawState: (uint)PhysicsStateFlags.ReportCollisions, + Position: position, + Movement: null, + AnimationFrame: null, + SetupTableId: 0x02000001u, + MotionTableId: null, + 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, + guid.ToString("X8"), + null, + null, + null, + PhysicsState: physics.RawState, + InstanceSequence: incarnation, + MovementSequence: 1, + ServerControlSequence: 1, + PositionSequence: 1, + Physics: physics); + } + + private sealed class RecordingResetHost(GameRuntime runtime) + : IRuntimeGenerationResetHost + { + public List Retired { get; } = []; + public int DrainCalls { get; private set; } + public int CompleteCalls { get; private set; } + public bool FailDrainOnce { get; set; } + public bool FailCompleteOnce { get; set; } + public bool Reenter { get; set; } + public Exception? ReentrantFailure { get; private set; } + + public void RetireEntityProjection(RuntimeEntityRecord entity) + { + Retired.Add(entity); + if (!Reenter) + return; + Reenter = false; + ReentrantFailure = Record.Exception(() => + runtime.ResetGeneration( + runtime.GenerationReset + .CaptureSnapshot() + .RetiringGeneration, + this)); + } + + public void DrainEntityProjectionBoundary() + { + DrainCalls++; + if (!FailDrainOnce) + return; + FailDrainOnce = false; + throw new InvalidOperationException("injected drain failure"); + } + + public void CompleteEntityProjectionRetirement() + { + CompleteCalls++; + Assert.NotEqual(0u, runtime.PlayerIdentity.ServerGuid); + if (!FailCompleteOnce) + return; + FailCompleteOnce = false; + throw new InvalidOperationException("injected completion failure"); + } + } + + private sealed class RecordingObserver : IRuntimeEventObserver + { + public List Entity { get; } = []; + public List Inventory { get; } = []; + + public void OnLifecycle(in RuntimeLifecycleDelta delta) { } + public void OnCommand(in RuntimeCommandDelta delta) { } + public void OnEntity(in RuntimeEntityDelta delta) => + Entity.Add(delta.Stamp); + public void OnInventory(in RuntimeInventoryDelta delta) => + Inventory.Add(delta.Stamp); + public void OnChat(in RuntimeChatDelta delta) { } + public void OnMovement(in RuntimeMovementDelta delta) { } + public void OnPortal(in RuntimePortalDelta delta) { } + } + + private sealed class Operations : + IRuntimeCombatAttackOperations, + IRuntimeCombatTargetOperations, + IRuntimeCombatModeOperations, + IRuntimeSpellCastOperations + { + public bool CanStartAttack() => false; + public void PrepareAttackRequest() { } + public bool SendAttack(AttackHeight height, float power) => false; + public void SendCancelAttack() { } + public bool IsDualWield => false; + public bool PlayerReadyForAttack => false; + public bool AutoRepeatAttack => false; + public bool AutoTarget => false; + public uint? SelectClosestTarget() => null; + public bool IsInWorld => false; + public IReadOnlyList GetOrderedEquipment() => []; + public void NotifyExplicitCombatModeRequest() { } + public void SendChangeCombatMode(CombatMode mode) { } + public uint LocalPlayerId => 0u; + public bool CanSend => false; + public bool HasRequiredComponents(uint spellId) => false; + public bool IsTargetCompatible( + uint targetId, + SpellMetadata spell, + bool showMessage) => false; + public void StopCompletely() { } + public void SendUntargeted(uint spellId) { } + public void SendTargeted(uint targetId, uint spellId) { } + public void DisplayMessage(string message) { } + public void IncrementBusy() { } + } +} diff --git a/tests/AcDream.Runtime.Tests/RuntimeSimulationFixtureHostTests.cs b/tests/AcDream.Runtime.Tests/RuntimeSimulationFixtureHostTests.cs index 145d32c4..e64c145f 100644 --- a/tests/AcDream.Runtime.Tests/RuntimeSimulationFixtureHostTests.cs +++ b/tests/AcDream.Runtime.Tests/RuntimeSimulationFixtureHostTests.cs @@ -94,29 +94,28 @@ public sealed class RuntimeSimulationFixtureHostTests public RuntimeOnlySimulationFixtureHost() { - EntityObjects = new RuntimeEntityObjectLifetime(); - Inventory = new RuntimeInventoryState(EntityObjects); - Character = new RuntimeCharacterState(); + Runtime = new GameRuntime(new GameRuntimeDependencies( + this, + this, + this, + this, + CombatTime: () => _now)); Character.Spellbook.InstallMetadata(SpellMetadata()); Character.Spellbook.OnSpellLearned(1u); - Communication = new RuntimeCommunicationState(); - Actions = new RuntimeActionState( - Inventory.Transactions, - Character.Spellbook, - this, - this, - this, - this, - now: () => _now); - Movement = new RuntimeLocalPlayerMovementState(); } - public RuntimeEntityObjectLifetime EntityObjects { get; } - public RuntimeInventoryState Inventory { get; } - public RuntimeCharacterState Character { get; } - public RuntimeCommunicationState Communication { get; } - public RuntimeActionState Actions { get; } - public RuntimeLocalPlayerMovementState Movement { get; } + public GameRuntime Runtime { get; } + public RuntimeEntityObjectLifetime EntityObjects => + Runtime.EntityObjects; + public RuntimeInventoryState Inventory => + Runtime.InventoryOwner; + public RuntimeCharacterState Character => + Runtime.CharacterOwner; + public RuntimeCommunicationState Communication => + Runtime.CommunicationOwner; + public RuntimeActionState Actions => Runtime.ActionOwner; + public RuntimeLocalPlayerMovementState Movement => + Runtime.MovementOwner; public List Trace { get; } = []; public void Move() @@ -219,13 +218,7 @@ public sealed class RuntimeSimulationFixtureHostTests } public RuntimeSimulationOwnershipSnapshot CaptureOwnership() => - RuntimeSimulationOwnership.Capture( - EntityObjects, - Inventory, - Character, - Communication, - Actions, - Movement); + Runtime.CaptureOwnership().Simulation; public bool IsInWorld => true; public uint LocalPlayerId => 0x50000001u; @@ -297,12 +290,7 @@ public sealed class RuntimeSimulationFixtureHostTests if (_disposed) return; _disposed = true; - Movement.Dispose(); - Actions.Dispose(); - Communication.Dispose(); - Character.Dispose(); - Inventory.Dispose(); - EntityObjects.Dispose(); + Runtime.Dispose(); } private static SpellTable SpellMetadata() diff --git a/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs index e51cbe62..e377ed31 100644 --- a/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs @@ -157,6 +157,7 @@ public sealed class LiveSessionControllerTests public int ActivateCount { get; private set; } public List CommandBuses { get; } = []; public List Selections { get; } = []; + public List ResetGenerations { get; } = []; public LiveSessionBinding BindSession(WorldSession session) { @@ -200,10 +201,12 @@ public sealed class LiveSessionControllerTests }); } - public void ResetSessionState() + public void ResetSessionState( + RuntimeGenerationToken retiringGeneration) { calls.Add("reset"); ResetCount++; + ResetGenerations.Add(retiringGeneration); OnReset?.Invoke(); if (FailResetOnce) { @@ -449,6 +452,34 @@ public sealed class LiveSessionControllerTests Assert.Same(operations.Sessions[1], controller.CurrentSession); } + [Fact] + public void ResetCallbacksCarryTheExactRetiringScopeGeneration() + { + var calls = new List(); + var operations = new TestOperations(calls); + var host = new TestHost(calls); + var controller = new LiveSessionController(operations); + + LiveSessionStartResult first = + controller.Start(LiveOptions(), host); + RuntimeGenerationToken firstGeneration = controller.Generation; + LiveSessionStartResult second = + controller.Reconnect(LiveOptions(), host); + RuntimeGenerationToken secondGeneration = controller.Generation; + controller.Stop(); + + Assert.Equal(LiveSessionStartStatus.Connected, first.Status); + Assert.Equal(LiveSessionStartStatus.Connected, second.Status); + Assert.Equal( + [ + RuntimeGenerationToken.Initial, + firstGeneration, + secondGeneration, + ], + host.ResetGenerations); + Assert.NotEqual(firstGeneration, secondGeneration); + } + [Theory] [InlineData("detach", "stop")] [InlineData("detach", "dispose")] diff --git a/tests/AcDream.Runtime.Tests/Session/LiveSessionHostTests.cs b/tests/AcDream.Runtime.Tests/Session/LiveSessionHostTests.cs index 89efa166..1cda8f2f 100644 --- a/tests/AcDream.Runtime.Tests/Session/LiveSessionHostTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/LiveSessionHostTests.cs @@ -221,7 +221,7 @@ public sealed class LiveSessionHostTests Func createCommands) => new(controller, new LiveSessionHostBindings( Routing: new(createEvents, createCommands), - Reset: () => calls.Add("reset"), + Reset: _ => calls.Add("reset"), Selection: new( id => calls.Add($"player:{id}"), id => calls.Add($"vitals:{id}"), diff --git a/tests/AcDream.Runtime.Tests/Session/LiveSessionLifecycleHostTests.cs b/tests/AcDream.Runtime.Tests/Session/LiveSessionLifecycleHostTests.cs index bac7638e..74b36ae1 100644 --- a/tests/AcDream.Runtime.Tests/Session/LiveSessionLifecycleHostTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/LiveSessionLifecycleHostTests.cs @@ -15,7 +15,7 @@ public sealed class LiveSessionLifecycleHostTests var host = CreateHost(calls); LiveSessionBinding binding = host.BindSession(sessionA); - host.ResetSessionState(); + host.ResetSessionState(RuntimeGenerationToken.Initial); host.ReportConnecting("host", 9000, "user"); host.ReportConnected(); var selection = new LiveSessionCharacterSelection(2, 3u, "toon", "account"); @@ -67,7 +67,7 @@ public sealed class LiveSessionLifecycleHostTests Func? bind = null) => new(new LiveSessionLifecycleBindings( Bind: bind ?? (session => CreateBinding(session, calls)), - Reset: () => calls.Add("reset"), + Reset: _ => calls.Add("reset"), Connecting: (host, port, user) => calls.Add($"connecting:{host}:{port}:{user}"), Connected: () => calls.Add("connected"), diff --git a/tests/AcDream.Runtime.Tests/Session/RuntimeLiveSessionNoWindowTests.cs b/tests/AcDream.Runtime.Tests/Session/RuntimeLiveSessionNoWindowTests.cs index 411de51b..38fb9544 100644 --- a/tests/AcDream.Runtime.Tests/Session/RuntimeLiveSessionNoWindowTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/RuntimeLiveSessionNoWindowTests.cs @@ -19,7 +19,7 @@ public sealed class RuntimeLiveSessionNoWindowTests new LiveSessionRoutingFactories( _ => new EventRoute(calls), _ => new CommandRoute(calls)), - () => calls.Add("reset"), + _ => calls.Add("reset"), new LiveSessionSelectionBindings( id => calls.Add($"player:{id}"), _ => { }, diff --git a/tests/AcDream.Runtime.Tests/Support/NoWindowGameRuntimeHost.cs b/tests/AcDream.Runtime.Tests/Support/NoWindowGameRuntimeHost.cs new file mode 100644 index 00000000..0603b0b8 --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Support/NoWindowGameRuntimeHost.cs @@ -0,0 +1,797 @@ +using System.Net; +using System.Numerics; +using AcDream.Core.Combat; +using AcDream.Core.Items; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Core.Spells; +using AcDream.Runtime.Entities; +using AcDream.Runtime.Gameplay; +using AcDream.Runtime.Physics; +using AcDream.Runtime.Session; +using AcDream.Runtime.World; + +namespace AcDream.Runtime.Tests.Support; + +internal sealed class NoWindowGameRuntimeHost : IDisposable +{ + private readonly FixtureOperations _operations; + private readonly GameplayOperations _gameplay; + private readonly ImmediateResetHost _resetHost = new(); + private readonly IDisposable _hostLease; + private readonly IDisposable _traceSubscription; + private EventRoute? _events; + private CommandRoute? _commands; + private bool _disposed; + + public NoWindowGameRuntimeHost( + string user = "runtime-user", + string password = "runtime-password", + uint characterId = 0x50000001u, + string characterName = "Runtime") + { + _operations = new FixtureOperations( + characterId, + characterName); + _gameplay = new GameplayOperations(); + Runtime = new GameRuntime(new GameRuntimeDependencies( + _gameplay, + _gameplay, + _gameplay, + _gameplay, + SessionOperations: _operations, + CombatTime: () => _gameplay.Now)); + _gameplay.Bind(Runtime); + _hostLease = Runtime.AcquireHostLease("no-window test host"); + Trace = new RuntimeTraceRecorder(); + _traceSubscription = Runtime.Subscribe(Trace); + Session = new LiveSessionHost( + Runtime.Session, + new LiveSessionHostBindings( + new LiveSessionRoutingFactories( + CreateEventRoute, + CreateCommandRoute), + (generation) => + Runtime.ResetGeneration(generation, _resetHost), + new LiveSessionSelectionBindings( + id => Runtime.PlayerIdentity.ServerGuid = id, + _ => { }, + Runtime.CommunicationOwner.Chat.SetLocalPlayerGuid, + _ => { }, + _ => { }, + Runtime.ActionOwner.Combat.Clear), + new LiveSessionEnteredWorldBindings( + _ => { }, + () => { }, + () => { }, + _ => { }, + () => { }), + (host, port, connectingUser) => + _operations.RecordConnecting( + host, + port, + connectingUser), + _operations.RecordConnected), + new LiveSessionConnectOptions( + true, + "127.0.0.1", + 9000, + user, + password)); + } + + public GameRuntime Runtime { get; } + public LiveSessionHost Session { get; } + public RuntimeTraceRecorder Trace { get; } + public IReadOnlyList LifecycleTrace => _operations.Trace; + public IReadOnlyList GameplayTrace => _gameplay.Trace; + public int ProjectionRetirementCount => + _resetHost.ProjectionRetirementCount; + public int ProjectionDrainCount => _resetHost.ProjectionDrainCount; + public int ProjectionCompletionCount => + _resetHost.ProjectionCompletionCount; + + public RuntimeStateCheckpoint CaptureCheckpoint() + { + RuntimeStateCheckpoint checkpoint = + Runtime.CaptureCheckpoint(); + Trace.AddCheckpoint( + Runtime.EntityObjects.Events.NextStamp(), + checkpoint); + return checkpoint; + } + + public RuntimeSessionStartResult Start() + { + RuntimeLifecycleState previous = Runtime.Lifecycle.State; + RuntimeSessionStartResult result = + Session.Start(Runtime.Generation); + Runtime.EventSink.EmitLifecycle( + previous, + Runtime.Lifecycle.State); + return result; + } + + public RuntimeSessionStartResult Reconnect() + { + RuntimeLifecycleState previous = Runtime.Lifecycle.State; + RuntimeSessionStartResult result = + Session.Reconnect(Runtime.Generation); + Runtime.EventSink.EmitLifecycle( + previous, + Runtime.Lifecycle.State); + return result; + } + + public RuntimeTeardownAcknowledgement Stop() + { + RuntimeLifecycleState previous = Runtime.Lifecycle.State; + RuntimeTeardownAcknowledgement result = + Session.Stop(Runtime.Generation); + Runtime.EventSink.EmitLifecycle( + previous, + Runtime.Lifecycle.State); + return result; + } + + public bool Deliver(Action delivery) => + Deliver(Runtime.Generation, delivery); + + public bool Deliver( + RuntimeGenerationToken expectedGeneration, + Action delivery) + { + ArgumentNullException.ThrowIfNull(delivery); + EventRoute? route = _events; + if (route is null + || !route.IsActive + || route.Generation != expectedGeneration + || expectedGeneration != Runtime.Generation) + { + return false; + } + delivery(Runtime); + return true; + } + + public bool Execute(Action command) => + Execute(Runtime.Generation, command); + + public bool Execute( + RuntimeGenerationToken expectedGeneration, + Action command) + { + ArgumentNullException.ThrowIfNull(command); + CommandRoute? route = _commands; + if (route is null + || !route.IsActive + || route.Generation != expectedGeneration + || expectedGeneration != Runtime.Generation) + { + return false; + } + command(Runtime); + return true; + } + + public bool Move() + { + return Execute(runtime => + { + Require( + runtime.MovementOwner.Execute( + RuntimeMovementCommand.ToggleRunLock), + "movement command was rejected"); + _gameplay.Trace.Add("movement:run"); + runtime.EventSink.EmitMovement( + runtime.MovementOwner.Snapshot); + runtime.EventSink.EmitCommand( + RuntimeCommandDomain.Movement, + (int)RuntimeMovementCommand.ToggleRunLock, + RuntimeCommandStatus.Accepted); + }); + } + + public bool Use(uint serverGuid) + { + return Execute(runtime => + { + ItemUseRequestReservation reservation = + runtime.ActionOwner.Transactions + .BeginUseRequestReservation(); + RuntimeInteractionDispatchResult result = + runtime.ActionOwner.Transactions.TryDispatchUse( + serverGuid, + ownedByPlayer: false, + useable: true, + reservation, + _gameplay, + out _); + Require( + result == RuntimeInteractionDispatchResult.Dispatched, + $"use command was rejected: {result}"); + runtime.ActionOwner.Transactions.CompleteUse(0u); + runtime.EventSink.EmitCommand( + RuntimeCommandDomain.Selection, + (int)RuntimeSelectionCommand.UseSelected, + RuntimeCommandStatus.Accepted, + serverGuid); + }); + } + + public bool Attack() + { + return Execute(runtime => + { + runtime.ActionOwner.Combat.SetCombatMode(CombatMode.Melee); + runtime.ActionOwner.CombatAttack.SetDesiredPower(0.5f); + runtime.ActionOwner.CombatAttack.PressAttack( + AttackHeight.Medium); + _gameplay.Now += 0.5d; + runtime.ActionOwner.CombatAttack.ReleaseAttack(); + runtime.EventSink.EmitCommand( + RuntimeCommandDomain.Combat, + (int)RuntimeCombatCommand.ToggleMode, + RuntimeCommandStatus.Accepted); + }); + } + + public bool CastFixtureSpell() + { + return Execute(runtime => + { + runtime.CharacterOwner.InstallSpellMetadata( + FixtureSpellMetadata()); + runtime.CharacterOwner.Spellbook.OnSpellLearned(1u); + CastRequestResult result = + runtime.ActionOwner.SpellCast.Cast(1u); + Require( + result == CastRequestResult.Sent, + $"spell command was rejected: {result}"); + runtime.EventSink.EmitCommand( + RuntimeCommandDomain.Magic, + operation: 1, + RuntimeCommandStatus.Accepted, + primaryObjectId: 1u); + }); + } + + public bool AdvanceProjectile( + uint serverGuid = 0x70000021u, + ushort incarnation = 1) + { + return Execute(runtime => + { + RuntimeEntityRecord record = + runtime.EntityObjects.RegisterEntity( + ProjectileSpawn(serverGuid, incarnation)).Canonical + ?? throw new InvalidOperationException( + "projectile registration produced no canonical record"); + PhysicsBody body = + runtime.EntityObjects.Physics.GetOrCreatePhysicsBody( + record, + static canonical => + { + var created = new PhysicsBody + { + Position = new Vector3(10f, 20f, 5f), + Orientation = Quaternion.Identity, + InWorld = true, + TransientState = + TransientStateFlags.Active, + }; + created.SnapToCell( + canonical.FullCellId, + created.Position, + created.Position); + return created; + }); + _ = runtime.EntityObjects.Physics.BindProjectile( + record, + body, + new ProjectileCollisionSphere( + Vector3.Zero, + 0.25f)); + runtime.EntityObjects.Physics.AcknowledgeSpatialProjection( + record, + spatial: true); + + var updater = new RuntimeProjectilePhysicsUpdater( + runtime.EntityObjects.Physics); + Require( + updater.TryBegin( + record, + quantum: 0.05f, + record.ObjectClockEpoch, + externalOwnerValid: null, + out RuntimeProjectilePhysicsCommit commit), + "projectile update did not begin"); + _ = updater.Complete( + commit, + liveCenterX: 1, + liveCenterY: 1, + _ => + { + _gameplay.Trace.Add("projectile:ack"); + return true; + }); + }); + } + + public bool CompletePortal( + uint destinationCell = 0x8A020164u, + ushort sequence = 1) + { + return Execute(runtime => + { + RuntimeWorldTransitState transit = runtime.TransitOwner; + Require( + transit.TryQueueTeleportStart(sequence), + "portal start was rejected"); + Require( + transit.ActivateQueuedTeleport(), + "portal activation was rejected"); + Require( + transit.OfferTeleportDestination( + new RuntimeTeleportDestination( + runtime.PlayerIdentity.ServerGuid, + InstanceSequence: 1, + PositionSequence: 1, + TeleportSequence: sequence, + ForcePositionSequence: 1, + Position: new Position( + destinationCell, + Vector3.Zero, + Quaternion.Identity)), + teleportTimestampAdvanced: true), + "portal destination was rejected"); + Require( + transit.TryBeginPortalReveal( + sequence, + destinationCell, + out long revealGeneration), + "portal reveal was rejected"); + EmitPortal(runtime); + Require( + transit.TryRegisterHostProjection( + revealGeneration, + destinationCell, + out RuntimeWorldHostProjectionToken projection), + "portal projection registration was rejected"); + Acknowledge( + transit, + projection, + RuntimeWorldHostAcknowledgementStage + .ProjectionRegistered); + bool indoor = (destinationCell & 0xFFFFu) >= 0x0100u; + Require( + transit.AcknowledgeDestinationReadiness( + new RuntimeDestinationReadiness( + revealGeneration, + destinationCell, + indoor, + IsUnhydratable: false, + RequiredRenderRadius: indoor ? 0 : 1, + IsRenderNeighborhoodReady: true, + AreCompositeTexturesReady: true, + IsCollisionReady: true)), + "portal readiness was rejected"); + EmitPortal(runtime); + Require( + transit.AcknowledgePortalMaterialized( + revealGeneration, + sequence, + destinationCell), + "portal materialization was rejected"); + EmitPortal(runtime); + DrainPortalAcknowledgements(transit, projection); + Require( + transit.RequireDestinationReservationRelease( + projection), + "portal reservation release was rejected"); + DrainPortalAcknowledgements(transit, projection); + Require( + transit.AcknowledgeWorldViewportVisible( + revealGeneration), + "portal viewport acknowledgement was rejected"); + EmitPortal(runtime); + Require( + transit.Complete(revealGeneration), + "portal completion was rejected"); + EmitPortal(runtime); + DrainPortalAcknowledgements(transit, projection); + }); + } + + public void Advance(double deltaSeconds) + { + _ = Runtime.Clock.Advance(deltaSeconds); + Runtime.Session.Tick(); + } + + public void Dispose() + { + if (_disposed) + return; + if (Runtime.Session.CurrentSession is not null) + _ = Stop(); + _traceSubscription.Dispose(); + _hostLease.Dispose(); + Runtime.Dispose(); + _disposed = true; + } + + private ILiveSessionEventRouting CreateEventRoute(WorldSession session) + { + var route = new EventRoute(Runtime.Generation, _operations.Trace); + _events = route; + return route; + } + + private static void Acknowledge( + RuntimeWorldTransitState transit, + RuntimeWorldHostProjectionToken projection, + RuntimeWorldHostAcknowledgementStage stage) => + Require( + transit.AcknowledgeHostProjection( + new RuntimeWorldHostAcknowledgement( + projection, + stage)), + $"portal host acknowledgement was rejected: {stage}"); + + private static void DrainPortalAcknowledgements( + RuntimeWorldTransitState transit, + RuntimeWorldHostProjectionToken projection) + { + while (transit.TryGetHostProjection( + projection, + out RuntimeWorldHostProjectionSnapshot pending)) + { + RuntimeWorldHostAcknowledgementStage stages = + pending.PendingAcknowledgements; + if ((stages & RuntimeWorldHostAcknowledgementStage + .SimulationReleaseProjected) != 0) + { + Acknowledge( + transit, + projection, + RuntimeWorldHostAcknowledgementStage + .SimulationReleaseProjected); + continue; + } + if ((stages & RuntimeWorldHostAcknowledgementStage + .DestinationReservationReleased) != 0) + { + Acknowledge( + transit, + projection, + RuntimeWorldHostAcknowledgementStage + .DestinationReservationReleased); + continue; + } + if ((stages & RuntimeWorldHostAcknowledgementStage + .TerminalProjected) != 0) + { + Acknowledge( + transit, + projection, + RuntimeWorldHostAcknowledgementStage + .TerminalProjected); + continue; + } + break; + } + } + + private static void EmitPortal(GameRuntime runtime) => + runtime.EventSink.EmitPortal(runtime.TransitOwner.Snapshot); + + private static SpellTable FixtureSpellMetadata() + { + const string header = + "Spell ID,Name,Flags [Hex],IsUntargetted,TargetMask [Hex]"; + const string row = "1,Runtime Fixture,0x0,true,0x0"; + return SpellTable.LoadFromReader( + new System.IO.StringReader($"{header}\n{row}")); + } + + private static WorldSession.EntitySpawn ProjectileSpawn( + uint guid, + ushort incarnation) + { + var position = new CreateObject.ServerPosition( + 0x01010001u, + 10f, + 20f, + 5f, + 1f, + 0f, + 0f, + 0f); + var timestamps = new PhysicsTimestamps( + Position: 1, + Movement: 1, + State: 1, + Vector: 1, + Teleport: 0, + ServerControlledMove: 1, + ForcePosition: 0, + ObjDesc: 1, + Instance: incarnation); + var physics = new PhysicsSpawnData( + RawState: (uint)( + PhysicsStateFlags.ReportCollisions + | PhysicsStateFlags.Missile), + Position: position, + Movement: null, + AnimationFrame: null, + SetupTableId: 0x02000001u, + MotionTableId: null, + SoundTableId: null, + PhysicsScriptTableId: null, + Parent: null, + Children: null, + Scale: null, + Friction: null, + Elasticity: null, + Translucency: null, + Velocity: new Vector3(10f, 0f, 0f), + 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, + "runtime projectile", + null, + null, + null, + PhysicsState: physics.RawState, + InstanceSequence: incarnation, + MovementSequence: 1, + ServerControlSequence: 1, + PositionSequence: 1, + Physics: physics); + } + + private static void Require(bool condition, string message) + { + if (!condition) + throw new InvalidOperationException(message); + } + + private ILiveSessionCommandRouting CreateCommandRoute( + WorldSession session) + { + var route = new CommandRoute(Runtime.Generation, _operations.Trace); + _commands = route; + return route; + } + + private sealed class ImmediateResetHost : IRuntimeGenerationResetHost + { + public int ProjectionRetirementCount { get; private set; } + public int ProjectionDrainCount { get; private set; } + public int ProjectionCompletionCount { get; private set; } + + public void RetireEntityProjection(RuntimeEntityRecord entity) => + ProjectionRetirementCount++; + + public void DrainEntityProjectionBoundary() => + ProjectionDrainCount++; + + public void CompleteEntityProjectionRetirement() => + ProjectionCompletionCount++; + } + + private sealed class EventRoute( + RuntimeGenerationToken generation, + List trace) : ILiveSessionEventRouting + { + public RuntimeGenerationToken Generation { get; } = generation; + public bool IsActive { get; private set; } + + public void Attach() + { + IsActive = true; + trace.Add($"events+:{Generation.Value}"); + } + + public void Dispose() + { + IsActive = false; + trace.Add($"events-:{Generation.Value}"); + } + } + + private sealed class CommandRoute( + RuntimeGenerationToken generation, + List trace) : ILiveSessionCommandRouting + { + public RuntimeGenerationToken Generation { get; } = generation; + public bool IsActive { get; private set; } + + public void Activate() + { + IsActive = true; + trace.Add($"commands+:{Generation.Value}"); + } + + public void Dispose() + { + IsActive = false; + trace.Add($"commands-:{Generation.Value}"); + } + } + + private sealed class FixtureOperations( + uint characterId, + string characterName) : ILiveSessionOperations + { + public List Trace { get; } = []; + + public IPEndPoint ResolveEndpoint(string host, int port) + { + Trace.Add($"resolve:{host}:{port}"); + return new IPEndPoint(IPAddress.Loopback, port); + } + + public WorldSession CreateSession(IPEndPoint endpoint) + { + Trace.Add("session+"); + return new WorldSession(endpoint, new FixtureTransport()); + } + + public void Connect( + WorldSession session, + string user, + string password) => + Trace.Add($"connect:{user}"); + + public CharacterList.Parsed GetCharacters(WorldSession session) + { + Trace.Add("characters"); + return new CharacterList.Parsed( + 0u, + [new CharacterList.Character( + characterId, + characterName, + 0u)], + [], + 11, + "NoWindow", + true, + true); + } + + public void EnterWorld( + WorldSession session, + int activeCharacterIndex) => + Trace.Add($"enter:{activeCharacterIndex}"); + + public void Tick(WorldSession session) => Trace.Add("tick"); + + public void DisposeSession(WorldSession session) + { + Trace.Add("session-"); + session.Dispose(); + } + + public void RecordConnecting( + string host, + int port, + string user) => + Trace.Add($"connecting:{host}:{port}:{user}"); + + public void RecordConnected() => Trace.Add("connected"); + } + + private sealed class FixtureTransport : IWorldSessionTransport + { + public void Send(ReadOnlySpan datagram) { } + public void Send(IPEndPoint remote, ReadOnlySpan datagram) { } + + public int Receive( + Span destination, + TimeSpan timeout, + out IPEndPoint? from) + { + from = null; + return -1; + } + + public ValueTask ReceiveAsync( + Memory destination, + CancellationToken cancellationToken) => + throw new OperationCanceledException(cancellationToken); + + public void Dispose() { } + } + + private sealed class GameplayOperations : + IRuntimeInteractionTransport, + IRuntimeCombatAttackOperations, + IRuntimeCombatTargetOperations, + IRuntimeCombatModeOperations, + IRuntimeSpellCastOperations + { + private GameRuntime? _runtime; + private uint _sequence; + + public double Now { get; set; } = 10d; + public List Trace { get; } = []; + + public void Bind(GameRuntime runtime) => + _runtime = runtime + ?? throw new ArgumentNullException(nameof(runtime)); + + public bool CanStartAttack() => IsInWorld; + public void PrepareAttackRequest() => + Trace.Add("attack:prepare"); + public bool SendAttack(AttackHeight height, float power) + { + Trace.Add($"attack:{height}:{power:0.0}"); + return true; + } + public void SendCancelAttack() { } + public bool IsDualWield => false; + public bool PlayerReadyForAttack => true; + public bool AutoRepeatAttack => false; + public bool AutoTarget => false; + public uint? SelectClosestTarget() => null; + public bool IsInWorld => _runtime?.Session.IsInWorld == true; + public IReadOnlyList GetOrderedEquipment() => []; + public void NotifyExplicitCombatModeRequest() { } + public void SendChangeCombatMode(CombatMode mode) => + Trace.Add($"combat:{mode}"); + public uint LocalPlayerId => + _runtime?.PlayerIdentity.ServerGuid ?? 0u; + public bool CanSend => IsInWorld; + public bool HasRequiredComponents(uint spellId) => true; + public bool IsTargetCompatible( + uint targetId, + SpellMetadata spell, + bool showMessage) => true; + public void StopCompletely() => + Trace.Add("cast:stop"); + public void SendUntargeted(uint spellId) => + Trace.Add($"cast:{spellId}"); + public void SendTargeted(uint targetId, uint spellId) => + Trace.Add($"cast:{targetId}:{spellId}"); + public void DisplayMessage(string message) => + Trace.Add($"message:{message}"); + public void IncrementBusy() + { + _runtime?.InventoryOwner.Transactions.IncrementBusyCount(); + Trace.Add("cast:busy"); + } + + public bool TrySendUse(uint serverGuid, out uint sequence) + { + sequence = ++_sequence; + Trace.Add($"use:{serverGuid:X8}"); + return true; + } + + public bool TrySendPickup( + uint itemGuid, + uint destinationContainerId, + int placement, + out uint sequence) + { + sequence = ++_sequence; + Trace.Add( + $"pickup:{itemGuid:X8}:{destinationContainerId:X8}:{placement}"); + return true; + } + } +}