From 96ddd16539e670a5ce9b1f86677530e3fbc3f635 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 26 Jul 2026 18:51:17 +0200 Subject: [PATCH] refactor(runtime): compose canonical game runtime root --- .../2026-07-26-modern-runtime-slice-j7.md | 165 +++++ src/AcDream.Runtime/GameRuntime.cs | 658 ++++++++++++++++++ src/AcDream.Runtime/GameRuntimeEventHub.cs | 403 +++++++++++ .../RuntimeLocalPlayerIdentityState.cs | 54 ++ .../Session/LiveSessionController.cs | 42 ++ .../AcDream.Runtime.Tests/GameRuntimeTests.cs | 263 +++++++ 6 files changed, 1585 insertions(+) create mode 100644 docs/plans/2026-07-26-modern-runtime-slice-j7.md create mode 100644 src/AcDream.Runtime/GameRuntime.cs create mode 100644 src/AcDream.Runtime/GameRuntimeEventHub.cs create mode 100644 src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerIdentityState.cs create mode 100644 tests/AcDream.Runtime.Tests/GameRuntimeTests.cs diff --git a/docs/plans/2026-07-26-modern-runtime-slice-j7.md b/docs/plans/2026-07-26-modern-runtime-slice-j7.md new file mode 100644 index 00000000..fcffac80 --- /dev/null +++ b/docs/plans/2026-07-26-modern-runtime-slice-j7.md @@ -0,0 +1,165 @@ +# Modern runtime Slice J7 — one `GameRuntime` composition root + +**Status:** ACTIVE +**Parent:** `2026-07-25-modern-runtime-slice-j.md`, J7 +**Authorization:** the user approved Slices F–L, including the J gameplay-owner +gates, on 2026-07-24 +**Purpose:** compose the already-proven Runtime lifetime groups behind one +instance-scoped owner without changing retail behavior, update order, command +latency, rendering, retained UI, or portal presentation. + +## 1. Baseline problem + +The canonical state is already in `AcDream.Runtime`, but production constructs +its owners at separate graphical composition points: + +| Owner | Baseline construction site | +|---|---| +| entity/object/physics lifetime | `GameWindow` field initializer | +| inventory, character, communication, actions | `GameWindow` constructor | +| local movement | `GameWindow` field initializer | +| world environment | `GameWindow` through `WorldEnvironmentController` | +| world transit/reveal | `LivePresentationComposition` | +| session/transport | `SessionPlayerComposition` | +| simulation clock | inside App's `UpdateFrameClock` | +| normalized view/events | App `CurrentGameRuntime*Adapter` objects | + +These are not duplicate gameplay models, but the split construction and +teardown graph makes it possible to assemble incompatible owners or retire +them in the wrong order. It also prevents a no-window host from constructing +the exact same complete kernel in one operation. + +## 2. Fixed boundary + +Add one public, presentation-independent `GameRuntime` in +`AcDream.Runtime`. It owns exactly: + +- canonical session/transport lifetime; +- local player server identity; +- entity/object/physics lifetime; +- inventory, character, communication, and action lifetime groups; +- local movement intent/controller lifetime; +- world environment and world transit/reveal state; +- the instance simulation clock; +- normalized borrowed views and the lazy ordered Runtime event source; +- the terminal ownership ledger and retryable teardown cursor. + +`GameRuntime` may depend only on Runtime's existing operation interfaces, +Core/Core.Net/Content/Plugin.Abstractions, and injected clock/log/session +operations. It never imports App, UI, Silk.NET, OpenAL, Arch, or ImGui. + +App keeps: + +- DAT/GL/audio/UI objects; +- physical input, camera, selection presentation, and command translation; +- render-space origin and portal tunnel presentation; +- synchronous adapters that borrow one `GameRuntime`; +- generation-scoped graphical resource receipts. + +Input, command execution, UI state reads, simulation, and rendering stay in the +same host frame. No queue, snapshot copy, worker hop, or extra frame boundary is +introduced. + +## 3. Ownership invariants + +1. A production `GameWindow` stores exactly one `GameRuntime` field. +2. Composition dependency records receive that root, not independently paired + Runtime owners. +3. Runtime children are created once by the root and are never replaced. +4. Graphical adapters may borrow children but cannot dispose or rebind them. +5. The session is quiesced by the root before App route/projection teardown. +6. App projection owners retire before the Runtime root's terminal disposal. +7. Root disposal is ordered, retryable, and resumes at the exact failed suffix. +8. Construction failure retires every already-created child in reverse order. +9. The event source attaches to owner events only while observers exist and is + detached before owner disposal. +10. A disposed graphical adapter is inert without disposing the Runtime root. +11. Two roots never share identity, clock, event sequence, world, gameplay, + physics, or session state. +12. Root construction and direct views load no graphical/audio/UI backend. + +## 4. Execution + +### J7.1 — Runtime root, identity, events, and terminal ledger + +- Add `GameRuntime`, typed construction dependencies, local-player identity, + construction fault points, ownership snapshot, and exact teardown stages. +- Move the normalized view/checkpoint implementation into the root. +- Move the lazy entity/inventory/chat event fanout into Runtime. +- Add session ownership accounting. +- Prove reverse construction rollback, exact retryable disposal, two-instance + isolation, lazy subscriptions, and zero presentation dependencies. + +Gate: focused Runtime tests pass and dependency/source guards find no App or +backend reference. + +### J7.2 — production composition cutover + +- Construct one root in `GameWindow`. +- Make `UpdateFrameClock` borrow the root clock. +- Make `WorldEnvironmentController` and `WorldGenerationAvailabilityState` + graphical adapters over root children. +- Make live presentation borrow root transit rather than construct it. +- Make session composition borrow root session rather than construct/transfer + it. +- Change composition dependency records to carry the root instead of + independent Runtime owners. +- Replace App's view/event reconstruction with one inertable graphical command + adapter over the root. +- Delete superseded App view/event adapters and direct production constructors. + +Gate: App composition/failure-injection tests prove one exact root and no +partial publication or borrowed-owner disposal. + +### J7.3 — one shutdown transaction + +- Replace individual Runtime roots in `GameWindowShutdownRoots` with + `GameRuntime`. +- Quiesce its session at the existing ingress barrier. +- Retire every graphical borrower before terminal root disposal. +- Make root disposal preserve the exact suffix through callback failure and + re-entrancy. +- Extend checkpoint/terminal ledgers with root/session/event ownership. + +Gate: every shutdown fault point converges on retry; no subscriber, route, +entity, physics, gameplay, transit, or session debt remains. + +### J7.4 — validation and closeout + +- Focused Runtime and App tests. +- Release solution build. +- Complete Release suite. +- Exact-binary connected lifecycle/reconnect route. +- Canonical nine-stop route and allocation/frame-time comparison against J6. +- Physical-display visual checks for login, world objects, radar, inventory + paperdoll, use/approach, combat facing, portal tunnel, and portal exit. +- Update architecture, code structure, milestone, roadmap, parent/umbrella + plans, AGENTS/CLAUDE, divergence ledger, and durable memory. + +## 5. Adversarial matrix + +- construction failure after every child owner; +- disposal failure at every root teardown stage; +- re-entrant session stop/dispose; +- observer removal and addition during event dispatch; +- observer failure while another observer remains; +- App adapter disposal while Runtime remains live; +- root disposal while an App borrower is still registered; +- session start/reconnect during disposal; +- duplicate disposal and retry after partial teardown; +- two simultaneous roots with different clocks, identities, entities, portals, + and event observers; +- portal host acknowledgement pending at shutdown; +- graphical composition failure before and after session host creation; +- same-frame selection/use/combat/magic/UI response before and after cutover; +- no Runtime load of App/UI/Silk/OpenAL/Arch/ImGui. + +## 6. Commit and rollback discipline + +Each production sub-slice lands separately after its focused and complete +Release gates. Record its full production SHA and exact `git revert ` here +before beginning the next sub-slice. Documentation-only commits are not +behavior rollback points. + +No compatibility owner, service locator, asynchronous command queue, timeout, +forced-ready path, or alternate headless behavior is permitted. diff --git a/src/AcDream.Runtime/GameRuntime.cs b/src/AcDream.Runtime/GameRuntime.cs new file mode 100644 index 00000000..6c00445c --- /dev/null +++ b/src/AcDream.Runtime/GameRuntime.cs @@ -0,0 +1,658 @@ +using AcDream.Runtime.Entities; +using AcDream.Runtime.Gameplay; +using AcDream.Runtime.Session; +using AcDream.Runtime.World; + +namespace AcDream.Runtime; + +public sealed record GameRuntimeDependencies( + IRuntimeCombatAttackOperations CombatAttackOperations, + IRuntimeCombatTargetOperations CombatTargetOperations, + IRuntimeCombatModeOperations CombatModeOperations, + IRuntimeSpellCastOperations SpellCastOperations, + TimeProvider? TimeProvider = null, + Action? Log = null, + Action? TimeSyncDiagnostic = null, + ILiveSessionOperations? SessionOperations = null, + Func? CombatTime = null, + uint FirstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId, + int MaximumChatEntries = 500); + +[Flags] +public enum GameRuntimeTeardownStage +{ + None = 0, + HostLeasesReleased = 1 << 0, + EventsDetached = 1 << 1, + SessionDisposed = 1 << 2, + TransitReset = 1 << 3, + ActionsDisposed = 1 << 4, + MovementDisposed = 1 << 5, + CharacterDisposed = 1 << 6, + InventoryDisposed = 1 << 7, + CommunicationDisposed = 1 << 8, + IdentityDisposed = 1 << 9, + EntityObjectsDisposed = 1 << 10, + Complete = + HostLeasesReleased + | EventsDetached + | SessionDisposed + | TransitReset + | ActionsDisposed + | MovementDisposed + | CharacterDisposed + | InventoryDisposed + | CommunicationDisposed + | IdentityDisposed + | EntityObjectsDisposed, +} + +public readonly record struct GameRuntimeOwnershipSnapshot( + bool IsDisposeRequested, + bool IsDisposeDrainActive, + bool IsDisposed, + int HostLeaseCount, + GameRuntimeTeardownStage CompletedTeardownStages, + LiveSessionOwnershipSnapshot Session, + RuntimeLocalPlayerIdentityOwnershipSnapshot PlayerIdentity, + RuntimeSimulationOwnershipSnapshot Simulation, + RuntimeWorldEnvironmentOwnershipSnapshot Environment, + RuntimeWorldTransitOwnershipSnapshot Transit, + GameRuntimeEventOwnershipSnapshot Events) +{ + public bool IsConverged => + IsDisposed + && IsDisposeRequested + && !IsDisposeDrainActive + && HostLeaseCount == 0 + && CompletedTeardownStages == GameRuntimeTeardownStage.Complete + && Session.IsConverged + && PlayerIdentity.IsConverged + && Simulation.IsConverged + && Transit.IsSessionIdle + && Events.IsConverged; +} + +internal enum GameRuntimeConstructionPoint +{ + ClockCreated, + SessionCreated, + PlayerIdentityCreated, + EntityObjectsCreated, + InventoryCreated, + CharacterCreated, + CommunicationCreated, + MovementCreated, + ActionsCreated, + EnvironmentCreated, + TransitCreated, + EventsCreated, +} + +internal sealed class GameRuntimeConstructionContext +{ + public LiveSessionController? Session { get; set; } + public RuntimeLocalPlayerIdentityState? PlayerIdentity { get; set; } + public RuntimeEntityObjectLifetime? EntityObjects { get; set; } + public RuntimeInventoryState? Inventory { get; set; } + public RuntimeCharacterState? Character { get; set; } + public RuntimeCommunicationState? Communication { get; set; } + public RuntimeLocalPlayerMovementState? Movement { get; set; } + public RuntimeActionState? Actions { get; set; } + public GameRuntimeEventHub? Events { get; set; } +} + +/// +/// One instance-scoped, presentation-independent ownership root for the +/// complete live client kernel. Graphical and direct hosts borrow this exact +/// object graph; they never reconstruct or copy its mutable state. +/// +public sealed class GameRuntime + : IGameRuntimeView, + IRuntimeEventSource, + IDisposable +{ + private const int TeardownStageCount = 11; + + private readonly object _lifetimeGate = new(); + private readonly Dictionary _hostLeases = []; + private readonly GameRuntimeEventHub _events; + private long _nextHostLeaseId; + private int _disposeStage; + private bool _disposeRequested; + private bool _disposeDrainActive; + private bool _disposed; + + public GameRuntime(GameRuntimeDependencies dependencies) + : this(dependencies, faultInjection: null) + { + } + + internal GameRuntime( + GameRuntimeDependencies dependencies, + Action? + faultInjection) + { + ArgumentNullException.ThrowIfNull(dependencies); + ArgumentNullException.ThrowIfNull( + dependencies.CombatAttackOperations); + ArgumentNullException.ThrowIfNull( + dependencies.CombatTargetOperations); + ArgumentNullException.ThrowIfNull( + dependencies.CombatModeOperations); + ArgumentNullException.ThrowIfNull( + dependencies.SpellCastOperations); + if (dependencies.MaximumChatEntries <= 0) + { + throw new ArgumentOutOfRangeException( + nameof(dependencies.MaximumChatEntries)); + } + + var context = new GameRuntimeConstructionContext(); + var construction = new ConstructionTransaction(); + try + { + var clock = new GameRuntimeClock(); + Fault( + GameRuntimeConstructionPoint.ClockCreated, + context, + faultInjection); + + context.Session = dependencies.SessionOperations is null + ? new LiveSessionController() + : new LiveSessionController(dependencies.SessionOperations); + construction.Own(context.Session); + Fault( + GameRuntimeConstructionPoint.SessionCreated, + context, + faultInjection); + + context.PlayerIdentity = new RuntimeLocalPlayerIdentityState(); + construction.Own(context.PlayerIdentity); + Fault( + GameRuntimeConstructionPoint.PlayerIdentityCreated, + context, + faultInjection); + + context.EntityObjects = new RuntimeEntityObjectLifetime( + dependencies.FirstLocalEntityId); + construction.Own(context.EntityObjects); + Fault( + GameRuntimeConstructionPoint.EntityObjectsCreated, + context, + faultInjection); + + context.Inventory = new RuntimeInventoryState( + context.EntityObjects); + construction.Own(context.Inventory); + Fault( + GameRuntimeConstructionPoint.InventoryCreated, + context, + faultInjection); + + context.Character = new RuntimeCharacterState(); + construction.Own(context.Character); + Fault( + GameRuntimeConstructionPoint.CharacterCreated, + context, + faultInjection); + + context.Communication = new RuntimeCommunicationState( + dependencies.MaximumChatEntries); + construction.Own(context.Communication); + Fault( + GameRuntimeConstructionPoint.CommunicationCreated, + context, + faultInjection); + + context.Movement = new RuntimeLocalPlayerMovementState(); + construction.Own(context.Movement); + Fault( + GameRuntimeConstructionPoint.MovementCreated, + context, + faultInjection); + + context.Actions = new RuntimeActionState( + context.Inventory.Transactions, + context.Character.Spellbook, + dependencies.CombatAttackOperations, + dependencies.CombatTargetOperations, + dependencies.CombatModeOperations, + dependencies.SpellCastOperations, + dependencies.CombatTime); + construction.Own(context.Actions); + Fault( + GameRuntimeConstructionPoint.ActionsCreated, + context, + faultInjection); + + var environment = new RuntimeWorldEnvironmentState( + dependencies.TimeProvider, + dependencies.Log, + dependencies.TimeSyncDiagnostic); + Fault( + GameRuntimeConstructionPoint.EnvironmentCreated, + context, + faultInjection); + + var transit = new RuntimeWorldTransitState(dependencies.Log); + Fault( + GameRuntimeConstructionPoint.TransitCreated, + context, + faultInjection); + + context.EntityObjects.BindEventContext( + () => context.Session.Generation, + () => clock.FrameNumber); + context.Events = new GameRuntimeEventHub( + context.EntityObjects, + context.Communication); + construction.Own(context.Events); + Fault( + GameRuntimeConstructionPoint.EventsCreated, + context, + faultInjection); + + Clock = clock; + Session = context.Session; + PlayerIdentity = context.PlayerIdentity; + EntityObjects = context.EntityObjects; + InventoryOwner = context.Inventory; + CharacterOwner = context.Character; + CommunicationOwner = context.Communication; + MovementOwner = context.Movement; + ActionOwner = context.Actions; + EnvironmentOwner = environment; + TransitOwner = transit; + _events = context.Events; + construction.Complete(); + } + catch (Exception failure) + { + construction.RollbackAndThrow(failure); + throw new System.Diagnostics.UnreachableException(); + } + } + + public GameRuntimeClock Clock { get; } + public LiveSessionController Session { get; } + public RuntimeLocalPlayerIdentityState PlayerIdentity { get; } + public RuntimeEntityObjectLifetime EntityObjects { get; } + public RuntimeInventoryState InventoryOwner { get; } + public RuntimeCharacterState CharacterOwner { get; } + public RuntimeCommunicationState CommunicationOwner { get; } + public RuntimeActionState ActionOwner { get; } + public RuntimeLocalPlayerMovementState MovementOwner { get; } + public RuntimeWorldEnvironmentState EnvironmentOwner { get; } + public RuntimeWorldTransitState TransitOwner { get; } + + public RuntimeGenerationToken Generation => Session.Generation; + + public RuntimeLifecycleSnapshot Lifecycle + { + get + { + RuntimeLifecycleState state; + if (_disposed) + state = RuntimeLifecycleState.Disposed; + else if (Session.IsInWorld) + state = RuntimeLifecycleState.InWorld; + else if (Session.CurrentSession is not null) + state = RuntimeLifecycleState.Starting; + else if (Session.SessionGeneration == 0UL) + state = RuntimeLifecycleState.Constructed; + else + state = RuntimeLifecycleState.Stopped; + + return new RuntimeLifecycleSnapshot( + Generation, + state, + PlayerIdentity.ServerGuid, + Session.CurrentSession is not null); + } + } + + IGameRuntimeClock IGameRuntimeView.Clock => Clock; + public IRuntimeEntityView Entities => EntityObjects.EntityView; + public IRuntimeInventoryView Inventory => EntityObjects.InventoryView; + public IRuntimeInventoryStateView InventoryState => InventoryOwner.View; + public IRuntimeCharacterView Character => CharacterOwner.View; + public IRuntimeSocialView Social => CommunicationOwner.SocialView; + public IRuntimeChatView Chat => CommunicationOwner.View; + public IRuntimeActionView Actions => ActionOwner.View; + public IRuntimeMovementView Movement => MovementOwner.View; + public IRuntimeWorldEnvironmentView Environment => EnvironmentOwner; + public IRuntimePortalView Portal => TransitOwner; + + internal IGameRuntimeEventSink EventSink => _events; + + public RuntimeStateCheckpoint CaptureCheckpoint() => + new( + Generation, + Lifecycle.State, + Clock.FrameNumber, + Entities.Count, + Entities.MaterializedCount, + Inventory.ObjectCount, + Inventory.ContainerCount, + InventoryState.Snapshot, + Character.Snapshot, + Social.Snapshot, + Chat.Revision, + Chat.Count, + Actions.Snapshot, + Movement.Snapshot, + Environment.Snapshot, + Environment.Ownership, + Portal.Snapshot, + Portal.Ownership); + + public IDisposable Subscribe(IRuntimeEventObserver observer) + { + lock (_lifetimeGate) + { + ObjectDisposedException.ThrowIf( + _disposeRequested || _disposed, + this); + return _events.Subscribe(observer); + } + } + + public IDisposable AcquireHostLease(string name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + lock (_lifetimeGate) + { + ObjectDisposedException.ThrowIf( + _disposeRequested || _disposed, + this); + long id = checked(++_nextHostLeaseId); + _hostLeases.Add(id, name); + return new HostLease(this, id); + } + } + + public GameRuntimeOwnershipSnapshot CaptureOwnership() + { + lock (_lifetimeGate) + { + return new GameRuntimeOwnershipSnapshot( + _disposeRequested, + _disposeDrainActive, + _disposed, + _hostLeases.Count, + CompletedTeardownStages, + Session.CaptureOwnership(), + PlayerIdentity.CaptureOwnership(), + RuntimeSimulationOwnership.Capture( + EntityObjects, + InventoryOwner, + CharacterOwner, + CommunicationOwner, + ActionOwner, + MovementOwner), + EnvironmentOwner.CaptureOwnership(), + TransitOwner.CaptureOwnership(), + _events.CaptureOwnership()); + } + } + + /// + /// Makes transport and all per-generation routes inert while retaining the + /// root for ordered host projection teardown. + /// + public void StopSession() + { + Session.Dispose(); + if (!Session.IsDisposalComplete) + { + throw new InvalidOperationException( + "The Runtime session shutdown was deferred by a re-entrant callback."); + } + } + + public void Dispose() + { + lock (_lifetimeGate) + { + if (_disposed || _disposeDrainActive) + return; + _disposeRequested = true; + _disposeDrainActive = true; + } + + List? completedStageFailures = null; + try + { + while (_disposeStage < TeardownStageCount) + { + bool complete; + try + { + complete = DrainCurrentStage(); + } + catch (Exception error) + { + complete = IsCurrentStageComplete(); + if (!complete) + throw; + (completedStageFailures ??= []).Add(error); + } + + if (!complete) + { + throw new InvalidOperationException( + $"GameRuntime teardown stage {_disposeStage} did not complete."); + } + _disposeStage++; + } + + lock (_lifetimeGate) + _disposed = true; + } + finally + { + lock (_lifetimeGate) + _disposeDrainActive = false; + } + + if (completedStageFailures is not null) + { + throw new AggregateException( + "GameRuntime reached terminal ownership with callback failures.", + completedStageFailures); + } + } + + private GameRuntimeTeardownStage CompletedTeardownStages => + _disposeStage switch + { + <= 0 => GameRuntimeTeardownStage.None, + 1 => GameRuntimeTeardownStage.HostLeasesReleased, + 2 => GameRuntimeTeardownStage.HostLeasesReleased + | GameRuntimeTeardownStage.EventsDetached, + 3 => GameRuntimeTeardownStage.HostLeasesReleased + | GameRuntimeTeardownStage.EventsDetached + | GameRuntimeTeardownStage.SessionDisposed, + 4 => GameRuntimeTeardownStage.HostLeasesReleased + | GameRuntimeTeardownStage.EventsDetached + | GameRuntimeTeardownStage.SessionDisposed + | GameRuntimeTeardownStage.TransitReset, + 5 => GameRuntimeTeardownStage.HostLeasesReleased + | GameRuntimeTeardownStage.EventsDetached + | GameRuntimeTeardownStage.SessionDisposed + | GameRuntimeTeardownStage.TransitReset + | GameRuntimeTeardownStage.ActionsDisposed, + 6 => GameRuntimeTeardownStage.HostLeasesReleased + | GameRuntimeTeardownStage.EventsDetached + | GameRuntimeTeardownStage.SessionDisposed + | GameRuntimeTeardownStage.TransitReset + | GameRuntimeTeardownStage.ActionsDisposed + | GameRuntimeTeardownStage.MovementDisposed, + 7 => GameRuntimeTeardownStage.HostLeasesReleased + | GameRuntimeTeardownStage.EventsDetached + | GameRuntimeTeardownStage.SessionDisposed + | GameRuntimeTeardownStage.TransitReset + | GameRuntimeTeardownStage.ActionsDisposed + | GameRuntimeTeardownStage.MovementDisposed + | GameRuntimeTeardownStage.CharacterDisposed, + 8 => GameRuntimeTeardownStage.HostLeasesReleased + | GameRuntimeTeardownStage.EventsDetached + | GameRuntimeTeardownStage.SessionDisposed + | GameRuntimeTeardownStage.TransitReset + | GameRuntimeTeardownStage.ActionsDisposed + | GameRuntimeTeardownStage.MovementDisposed + | GameRuntimeTeardownStage.CharacterDisposed + | GameRuntimeTeardownStage.InventoryDisposed, + 9 => GameRuntimeTeardownStage.Complete + & ~GameRuntimeTeardownStage.IdentityDisposed + & ~GameRuntimeTeardownStage.EntityObjectsDisposed, + 10 => GameRuntimeTeardownStage.Complete + & ~GameRuntimeTeardownStage.EntityObjectsDisposed, + _ => GameRuntimeTeardownStage.Complete, + }; + + private bool DrainCurrentStage() + { + switch (_disposeStage) + { + case 0: + lock (_lifetimeGate) + { + if (_hostLeases.Count != 0) + { + throw new InvalidOperationException( + "GameRuntime cannot retire while host leases remain: " + + string.Join(", ", _hostLeases.Values)); + } + } + return true; + case 1: + _events.Dispose(); + return _events.CaptureOwnership().IsConverged; + case 2: + StopSession(); + return Session.CaptureOwnership().IsConverged; + case 3: + TransitOwner.ResetSession(); + return TransitOwner.CaptureOwnership().IsSessionIdle; + case 4: + ActionOwner.Dispose(); + return ActionOwner.CaptureOwnership().IsConverged; + case 5: + MovementOwner.Dispose(); + return MovementOwner.CaptureOwnership().IsConverged; + case 6: + CharacterOwner.Dispose(); + return CharacterOwner.CaptureOwnership().IsConverged; + case 7: + InventoryOwner.Dispose(); + return InventoryOwner.CaptureOwnership().IsConverged; + case 8: + CommunicationOwner.Dispose(); + return CommunicationOwner.CaptureOwnership().IsConverged; + case 9: + PlayerIdentity.Dispose(); + return PlayerIdentity.CaptureOwnership().IsConverged; + case 10: + EntityObjects.Dispose(); + return EntityObjects.CaptureOwnership().IsConverged + && EntityObjects.Physics.CaptureOwnership().IsConverged; + default: + return true; + } + } + + private bool IsCurrentStageComplete() => + _disposeStage switch + { + 0 => HostLeaseCount == 0, + 1 => _events.CaptureOwnership().IsConverged, + 2 => Session.CaptureOwnership().IsConverged, + 3 => TransitOwner.CaptureOwnership().IsSessionIdle, + 4 => ActionOwner.CaptureOwnership().IsConverged, + 5 => MovementOwner.CaptureOwnership().IsConverged, + 6 => CharacterOwner.CaptureOwnership().IsConverged, + 7 => InventoryOwner.CaptureOwnership().IsConverged, + 8 => CommunicationOwner.CaptureOwnership().IsConverged, + 9 => PlayerIdentity.CaptureOwnership().IsConverged, + 10 => EntityObjects.CaptureOwnership().IsConverged + && EntityObjects.Physics.CaptureOwnership().IsConverged, + _ => true, + }; + + private int HostLeaseCount + { + get + { + lock (_lifetimeGate) + return _hostLeases.Count; + } + } + + private void ReleaseHostLease(long id) + { + lock (_lifetimeGate) + _hostLeases.Remove(id); + } + + private static void Fault( + GameRuntimeConstructionPoint point, + GameRuntimeConstructionContext context, + Action? + faultInjection) => + faultInjection?.Invoke(point, context); + + private sealed class HostLease(GameRuntime owner, long id) : IDisposable + { + private GameRuntime? _owner = owner; + + public void Dispose() => + Interlocked.Exchange(ref _owner, null)?.ReleaseHostLease(id); + } + + private sealed class ConstructionTransaction + { + private readonly List _owners = []; + private bool _complete; + + public void Own(IDisposable owner) + { + ArgumentNullException.ThrowIfNull(owner); + if (_complete) + throw new InvalidOperationException( + "Runtime construction already completed."); + _owners.Add(owner); + } + + public void Complete() + { + _complete = true; + _owners.Clear(); + } + + public void RollbackAndThrow(Exception failure) + { + var failures = new List { failure }; + for (int i = _owners.Count - 1; i >= 0; i--) + { + try + { + _owners[i].Dispose(); + } + catch (Exception cleanup) + { + failures.Add(cleanup); + } + } + _owners.Clear(); + if (failures.Count == 1) + System.Runtime.ExceptionServices.ExceptionDispatchInfo + .Capture(failure) + .Throw(); + throw new AggregateException( + "GameRuntime construction and rollback both failed.", + failures); + } + } +} diff --git a/src/AcDream.Runtime/GameRuntimeEventHub.cs b/src/AcDream.Runtime/GameRuntimeEventHub.cs new file mode 100644 index 00000000..76e6c76b --- /dev/null +++ b/src/AcDream.Runtime/GameRuntimeEventHub.cs @@ -0,0 +1,403 @@ +using AcDream.Runtime.Entities; +using AcDream.Runtime.Gameplay; + +namespace AcDream.Runtime; + +public readonly record struct GameRuntimeEventOwnershipSnapshot( + bool IsDisposeRequested, + bool IsDisposed, + bool OwnerEventsAttached, + int ObserverCount, + long DispatchFailureCount, + bool HasLastDispatchFailure) +{ + public bool IsConverged => + IsDisposed + && !OwnerEventsAttached + && ObserverCount == 0; +} + +internal interface IGameRuntimeEventSink +{ + void EmitCommand( + RuntimeCommandDomain domain, + int operation, + RuntimeCommandStatus status, + uint primaryObjectId = 0u, + string? text = null); + + void EmitLifecycle( + RuntimeLifecycleState previous, + RuntimeLifecycleState current); + + void EmitMovement(in RuntimeMovementSnapshot movement); + + void EmitPortal(in RuntimePortalSnapshot portal); +} + +/// +/// Lazy ordered event projection over one exact Runtime owner graph. With no +/// observers it attaches no owner callbacks and adds no production event work. +/// +internal sealed class GameRuntimeEventHub + : IRuntimeEventSource, + IGameRuntimeEventSink, + IRuntimeEntityObjectObserver, + IRuntimeCommunicationObserver, + IDisposable +{ + private readonly RuntimeEntityObjectLifetime _entityObjects; + private readonly RuntimeCommunicationState _communication; + private readonly object _observerGate = new(); + private IRuntimeEventObserver[] _observers = []; + private IDisposable? _entityObjectSubscription; + private IDisposable? _communicationSubscription; + private bool _disposeRequested; + private bool _disposed; + + public GameRuntimeEventHub( + RuntimeEntityObjectLifetime entityObjects, + RuntimeCommunicationState communication) + { + _entityObjects = entityObjects + ?? throw new ArgumentNullException(nameof(entityObjects)); + _communication = communication + ?? throw new ArgumentNullException(nameof(communication)); + } + + public long DispatchFailureCount { get; private set; } + public Exception? LastDispatchFailure { get; private set; } + + public GameRuntimeEventOwnershipSnapshot CaptureOwnership() + { + lock (_observerGate) + { + return new GameRuntimeEventOwnershipSnapshot( + _disposeRequested, + _disposed, + OwnerEventsAttached, + _observers.Length, + DispatchFailureCount, + LastDispatchFailure is not null); + } + } + + public IDisposable Subscribe(IRuntimeEventObserver observer) + { + ArgumentNullException.ThrowIfNull(observer); + lock (_observerGate) + { + ObjectDisposedException.ThrowIf( + _disposeRequested || _disposed, + this); + IRuntimeEventObserver[] current = _observers; + if (Array.IndexOf(current, observer) >= 0) + { + throw new InvalidOperationException( + "The runtime observer is already subscribed."); + } + + if (!OwnerEventsAttached) + AttachOwnerEvents(); + + var replacement = new IRuntimeEventObserver[current.Length + 1]; + Array.Copy(current, replacement, current.Length); + replacement[^1] = observer; + Volatile.Write(ref _observers, replacement); + } + + return new ObserverSubscription(this, observer); + } + + public void Dispose() + { + lock (_observerGate) + { + if (_disposed) + return; + _disposeRequested = true; + Volatile.Write(ref _observers, []); + + List? failures = null; + TryDispose( + ref _communicationSubscription, + "communication event subscription", + ref failures); + TryDispose( + ref _entityObjectSubscription, + "entity/object event subscription", + ref failures); + _disposed = !OwnerEventsAttached; + + if (failures is not null) + { + throw new AggregateException( + "Runtime event subscriptions did not converge.", + failures); + } + } + } + + public void EmitCommand( + RuntimeCommandDomain domain, + int operation, + RuntimeCommandStatus status, + uint primaryObjectId = 0u, + string? text = null) + { + if (!TryObservers(out IRuntimeEventObserver[] observers)) + return; + var delta = new RuntimeCommandDelta( + NextStamp(), + domain, + operation, + status, + primaryObjectId, + text); + foreach (IRuntimeEventObserver observer in observers) + { + try + { + observer.OnCommand(in delta); + } + catch (Exception error) + { + RecordDispatchFailure(error); + } + } + } + + public void EmitLifecycle( + RuntimeLifecycleState previous, + RuntimeLifecycleState current) + { + if (current == previous + || !TryObservers(out IRuntimeEventObserver[] observers)) + { + return; + } + + var delta = new RuntimeLifecycleDelta( + NextStamp(), + previous, + current); + foreach (IRuntimeEventObserver observer in observers) + { + try + { + observer.OnLifecycle(in delta); + } + catch (Exception error) + { + RecordDispatchFailure(error); + } + } + } + + public void EmitMovement(in RuntimeMovementSnapshot movement) + { + if (!TryObservers(out IRuntimeEventObserver[] observers)) + return; + var delta = new RuntimeMovementDelta(NextStamp(), movement); + foreach (IRuntimeEventObserver observer in observers) + { + try + { + observer.OnMovement(in delta); + } + catch (Exception error) + { + RecordDispatchFailure(error); + } + } + } + + public void EmitPortal(in RuntimePortalSnapshot portal) + { + if (!TryObservers(out IRuntimeEventObserver[] observers)) + return; + var delta = new RuntimePortalDelta(NextStamp(), portal); + foreach (IRuntimeEventObserver observer in observers) + { + try + { + observer.OnPortal(in delta); + } + catch (Exception error) + { + RecordDispatchFailure(error); + } + } + } + + public void OnEntity(in RuntimeEntityDelta delta) + { + if (!TryObservers(out IRuntimeEventObserver[] observers)) + return; + foreach (IRuntimeEventObserver observer in observers) + { + try + { + observer.OnEntity(in delta); + } + catch (Exception error) + { + RecordDispatchFailure(error); + } + } + } + + public void OnInventory(in RuntimeInventoryDelta delta) + { + if (!TryObservers(out IRuntimeEventObserver[] observers)) + return; + foreach (IRuntimeEventObserver observer in observers) + { + try + { + observer.OnInventory(in delta); + } + catch (Exception error) + { + RecordDispatchFailure(error); + } + } + } + + public void OnChat(in RuntimeCommunicationEvent committed) + { + if (!TryObservers(out IRuntimeEventObserver[] observers)) + return; + var delta = new RuntimeChatDelta(NextStamp(), committed.Entry); + foreach (IRuntimeEventObserver observer in observers) + { + try + { + observer.OnChat(in delta); + } + catch (Exception error) + { + RecordDispatchFailure(error); + } + } + } + + private bool OwnerEventsAttached => + _entityObjectSubscription is not null + || _communicationSubscription is not null; + + private void AttachOwnerEvents() + { + IDisposable? entity = null; + IDisposable? communication = null; + try + { + entity = _entityObjects.Events.Subscribe(this); + communication = _communication.Events.Subscribe(this); + _entityObjectSubscription = entity; + _communicationSubscription = communication; + } + catch + { + communication?.Dispose(); + entity?.Dispose(); + throw; + } + } + + private void Unsubscribe(IRuntimeEventObserver observer) + { + lock (_observerGate) + { + IRuntimeEventObserver[] current = _observers; + int index = Array.IndexOf(current, observer); + if (index < 0) + return; + if (current.Length == 1) + { + Volatile.Write(ref _observers, []); + DetachOwnerEvents(); + return; + } + + var replacement = new IRuntimeEventObserver[current.Length - 1]; + if (index != 0) + Array.Copy(current, 0, replacement, 0, index); + if (index != current.Length - 1) + { + Array.Copy( + current, + index + 1, + replacement, + index, + current.Length - index - 1); + } + Volatile.Write(ref _observers, replacement); + } + } + + private void DetachOwnerEvents() + { + List? failures = null; + TryDispose( + ref _communicationSubscription, + "communication event subscription", + ref failures); + TryDispose( + ref _entityObjectSubscription, + "entity/object event subscription", + ref failures); + if (failures is not null) + { + throw new AggregateException( + "Runtime event subscriptions did not detach.", + failures); + } + } + + private static void TryDispose( + ref IDisposable? subscription, + string name, + ref List? failures) + { + if (subscription is null) + return; + try + { + subscription.Dispose(); + subscription = null; + } + catch (Exception error) + { + (failures ??= []).Add(new InvalidOperationException( + $"{name} did not detach.", + error)); + } + } + + private RuntimeEventStamp NextStamp() => + _entityObjects.Events.NextStamp(); + + private bool TryObservers(out IRuntimeEventObserver[] observers) + { + observers = Volatile.Read(ref _observers); + return observers.Length != 0; + } + + private void RecordDispatchFailure(Exception error) + { + DispatchFailureCount++; + LastDispatchFailure = error; + } + + private sealed class ObserverSubscription( + GameRuntimeEventHub owner, + IRuntimeEventObserver observer) + : IDisposable + { + private GameRuntimeEventHub? _owner = owner; + + public void Dispose() => + Interlocked.Exchange(ref _owner, null)?.Unsubscribe(observer); + } +} diff --git a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerIdentityState.cs b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerIdentityState.cs new file mode 100644 index 00000000..194e7fb4 --- /dev/null +++ b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerIdentityState.cs @@ -0,0 +1,54 @@ +namespace AcDream.Runtime.Gameplay; + +public readonly record struct RuntimeLocalPlayerIdentityOwnershipSnapshot( + bool IsDisposed, + uint ServerGuid, + long Revision) +{ + public bool IsConverged => IsDisposed && ServerGuid == 0u; +} + +/// +/// Canonical session-scoped identity of the local player. Graphical and direct +/// hosts borrow this exact owner; presentation adapters may not mirror it. +/// +public sealed class RuntimeLocalPlayerIdentityState : IDisposable +{ + private uint _serverGuid; + private long _revision; + private bool _disposed; + + public uint ServerGuid + { + get => _serverGuid; + set + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_serverGuid == value) + return; + _serverGuid = value; + Interlocked.Increment(ref _revision); + } + } + + public long Revision => Interlocked.Read(ref _revision); + public bool IsDisposed => _disposed; + + public void ResetSession() + { + ObjectDisposedException.ThrowIf(_disposed, this); + ServerGuid = 0u; + } + + public RuntimeLocalPlayerIdentityOwnershipSnapshot CaptureOwnership() => + new(_disposed, _serverGuid, Revision); + + public void Dispose() + { + if (_disposed) + return; + _serverGuid = 0u; + Interlocked.Increment(ref _revision); + _disposed = true; + } +} diff --git a/src/AcDream.Runtime/Session/LiveSessionController.cs b/src/AcDream.Runtime/Session/LiveSessionController.cs index 75bb1048..d5292e76 100644 --- a/src/AcDream.Runtime/Session/LiveSessionController.cs +++ b/src/AcDream.Runtime/Session/LiveSessionController.cs @@ -15,6 +15,30 @@ public enum LiveSessionStartStatus Failed, } +public readonly record struct LiveSessionOwnershipSnapshot( + bool IsDisposed, + bool IsDisposeRequested, + bool IsInWorld, + bool HasActiveSession, + bool HasRetiredSession, + bool HasPendingInitialReset, + bool HasPendingOperation, + int OperationDepth, + ulong Generation, + RuntimeTeardownStage LastTeardownStages) +{ + public bool IsConverged => + IsDisposed + && IsDisposeRequested + && !IsInWorld + && !HasActiveSession + && !HasRetiredSession + && !HasPendingInitialReset + && !HasPendingOperation + && OperationDepth == 0 + && LastTeardownStages == RuntimeTeardownStage.Complete; +} + public sealed record LiveSessionCharacterSelection( int ActiveIndex, uint CharacterId, @@ -272,6 +296,24 @@ public sealed class LiveSessionController get { lock (_gate) return _disposed; } } + public LiveSessionOwnershipSnapshot CaptureOwnership() + { + lock (_gate) + { + return new LiveSessionOwnershipSnapshot( + _disposed, + _disposeRequested, + _inWorld, + _scope is not null, + _retiredScope is not null, + _pendingInitialResetHost is not null, + _pendingOperation is not null, + _operationDepth, + _generation, + _lastTeardownStages); + } + } + public LiveSessionStartResult Start( LiveSessionConnectOptions options, ILiveSessionLifecycleHost host) diff --git a/tests/AcDream.Runtime.Tests/GameRuntimeTests.cs b/tests/AcDream.Runtime.Tests/GameRuntimeTests.cs new file mode 100644 index 00000000..42ad270e --- /dev/null +++ b/tests/AcDream.Runtime.Tests/GameRuntimeTests.cs @@ -0,0 +1,263 @@ +using AcDream.Core.Combat; +using AcDream.Core.Items; +using AcDream.Core.Selection; +using AcDream.Core.Spells; +using AcDream.Runtime.Gameplay; + +namespace AcDream.Runtime.Tests; + +public sealed class GameRuntimeTests +{ + [Fact] + public void RootOwnsOneExactInstanceOfEveryRuntimeLifetimeGroup() + { + using var runtime = Create(); + + Assert.Same(runtime.EntityObjects.EntityView, runtime.Entities); + Assert.Same(runtime.EntityObjects.InventoryView, runtime.Inventory); + Assert.Same(runtime.InventoryOwner.View, runtime.InventoryState); + Assert.Same(runtime.CharacterOwner.View, runtime.Character); + Assert.Same(runtime.CommunicationOwner.View, runtime.Chat); + Assert.Same(runtime.CommunicationOwner.SocialView, runtime.Social); + Assert.Same(runtime.ActionOwner.View, runtime.Actions); + Assert.Same(runtime.MovementOwner.View, runtime.Movement); + Assert.Same(runtime.EnvironmentOwner, runtime.Environment); + Assert.Same(runtime.TransitOwner, runtime.Portal); + Assert.Same(runtime.Clock, ((IGameRuntimeView)runtime).Clock); + Assert.Equal(RuntimeLifecycleState.Constructed, runtime.Lifecycle.State); + } + + [Fact] + public void RootsNeverShareIdentityClockWorldGameplayOrSessionState() + { + using var first = Create(); + using var second = Create(); + + first.PlayerIdentity.ServerGuid = 0x50000001u; + _ = first.Clock.Advance(0.25); + first.ActionOwner.Selection.Select( + 0x70000001u, + SelectionChangeSource.System); + long reveal = first.TransitOwner.BeginLoginReveal(0x12340001u); + + Assert.Equal(0x50000001u, first.Lifecycle.PlayerGuid); + Assert.Equal(0u, second.Lifecycle.PlayerGuid); + Assert.Equal(1UL, first.Clock.FrameNumber); + Assert.Equal(0UL, second.Clock.FrameNumber); + Assert.Equal(0x70000001u, first.Actions.Snapshot.SelectedObjectId); + Assert.Equal(0u, second.Actions.Snapshot.SelectedObjectId); + Assert.Equal(reveal, first.Portal.Snapshot.Generation); + Assert.Equal(RuntimePortalKind.None, second.Portal.Snapshot.Kind); + Assert.NotSame(first.Session, second.Session); + Assert.NotSame(first.EntityObjects, second.EntityObjects); + } + + [Fact] + public void EventSourceIsLazyOrderedAndOwnedByTheRoot() + { + using var runtime = Create(); + GameRuntimeOwnershipSnapshot before = runtime.CaptureOwnership(); + Assert.False(before.Events.OwnerEventsAttached); + Assert.Equal(0, before.Events.ObserverCount); + + var observer = new RecordingObserver(); + using IDisposable subscription = runtime.Subscribe(observer); + GameRuntimeOwnershipSnapshot attached = runtime.CaptureOwnership(); + Assert.True(attached.Events.OwnerEventsAttached); + Assert.Equal(1, attached.Events.ObserverCount); + + runtime.CommunicationOwner.Chat.OnSystemMessage("hello", 1); + + RuntimeChatDelta chat = Assert.Single(observer.Chat); + Assert.Equal("hello", chat.Entry.Text); + Assert.Equal(runtime.Generation, chat.Stamp.Generation); + Assert.Equal(runtime.Clock.FrameNumber, chat.Stamp.FrameNumber); + + subscription.Dispose(); + GameRuntimeOwnershipSnapshot detached = runtime.CaptureOwnership(); + Assert.False(detached.Events.OwnerEventsAttached); + Assert.Equal(0, detached.Events.ObserverCount); + } + + [Fact] + public void HostLeaseBlocksTerminalDisposalUntilExactBorrowerReleases() + { + var runtime = Create(); + IDisposable lease = runtime.AcquireHostLease("graphical host"); + + InvalidOperationException blocked = Assert.Throws( + runtime.Dispose); + Assert.Contains("graphical host", blocked.Message); + GameRuntimeOwnershipSnapshot pending = runtime.CaptureOwnership(); + Assert.True(pending.IsDisposeRequested); + Assert.False(pending.IsDisposed); + Assert.Equal(1, pending.HostLeaseCount); + Assert.Equal(GameRuntimeTeardownStage.None, pending.CompletedTeardownStages); + + lease.Dispose(); + runtime.Dispose(); + + Assert.True(runtime.CaptureOwnership().IsConverged); + runtime.Dispose(); + Assert.True(runtime.CaptureOwnership().IsConverged); + } + + [Theory] + [InlineData((int)GameRuntimeConstructionPoint.ClockCreated)] + [InlineData((int)GameRuntimeConstructionPoint.SessionCreated)] + [InlineData((int)GameRuntimeConstructionPoint.PlayerIdentityCreated)] + [InlineData((int)GameRuntimeConstructionPoint.EntityObjectsCreated)] + [InlineData((int)GameRuntimeConstructionPoint.InventoryCreated)] + [InlineData((int)GameRuntimeConstructionPoint.CharacterCreated)] + [InlineData((int)GameRuntimeConstructionPoint.CommunicationCreated)] + [InlineData((int)GameRuntimeConstructionPoint.MovementCreated)] + [InlineData((int)GameRuntimeConstructionPoint.ActionsCreated)] + [InlineData((int)GameRuntimeConstructionPoint.EnvironmentCreated)] + [InlineData((int)GameRuntimeConstructionPoint.TransitCreated)] + [InlineData((int)GameRuntimeConstructionPoint.EventsCreated)] + public void ConstructionFailureRetiresEveryAcquiredOwnerInReverse( + int failurePointValue) + { + var failurePoint = (GameRuntimeConstructionPoint)failurePointValue; + GameRuntimeConstructionContext? captured = null; + + Assert.Throws(() => + new GameRuntime( + Dependencies(), + (point, context) => + { + if (point != failurePoint) + return; + captured = context; + throw new InjectedConstructionFailure(); + })); + + Assert.NotNull(captured); + if (captured.Session is not null) + Assert.True(captured.Session.CaptureOwnership().IsConverged); + if (captured.PlayerIdentity is not null) + Assert.True(captured.PlayerIdentity.CaptureOwnership().IsConverged); + if (captured.Actions is not null) + Assert.True(captured.Actions.CaptureOwnership().IsConverged); + if (captured.Movement is not null) + Assert.True(captured.Movement.CaptureOwnership().IsConverged); + if (captured.Character is not null) + Assert.True(captured.Character.CaptureOwnership().IsConverged); + if (captured.Inventory is not null) + Assert.True(captured.Inventory.CaptureOwnership().IsConverged); + if (captured.Communication is not null) + Assert.True(captured.Communication.CaptureOwnership().IsConverged); + if (captured.EntityObjects is not null) + { + Assert.True(captured.EntityObjects.CaptureOwnership().IsConverged); + Assert.True( + captured.EntityObjects.Physics.CaptureOwnership().IsConverged); + } + if (captured.Events is not null) + Assert.True(captured.Events.CaptureOwnership().IsConverged); + } + + [Fact] + public void DirectRootLoadsNoGraphicalAudioOrUiBackend() + { + using var runtime = Create(); + _ = runtime.CaptureCheckpoint(); + + 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)); + } + + [Fact] + public void TerminalDisposalConvergesCompleteOwnershipLedger() + { + var runtime = Create(); + runtime.PlayerIdentity.ServerGuid = 0x50000001u; + _ = runtime.MovementOwner.Execute( + RuntimeMovementCommand.ToggleRunLock); + runtime.ActionOwner.Selection.Select( + 0x70000001u, + SelectionChangeSource.System); + runtime.CommunicationOwner.Chat.OnSystemMessage("state", 1); + _ = runtime.Subscribe(new RecordingObserver()); + + runtime.Dispose(); + + GameRuntimeOwnershipSnapshot ownership = runtime.CaptureOwnership(); + Assert.True(ownership.IsConverged); + Assert.Equal( + GameRuntimeTeardownStage.Complete, + ownership.CompletedTeardownStages); + Assert.Equal(0, ownership.Events.ObserverCount); + Assert.Equal(0u, ownership.PlayerIdentity.ServerGuid); + } + + private static GameRuntime Create() => new(Dependencies()); + + private static GameRuntimeDependencies Dependencies() + { + var operations = new Operations(); + return new GameRuntimeDependencies( + operations, + operations, + operations, + operations); + } + + 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() { } + } + + private sealed class RecordingObserver : IRuntimeEventObserver + { + public List Chat { get; } = []; + + public void OnLifecycle(in RuntimeLifecycleDelta delta) { } + public void OnCommand(in RuntimeCommandDelta delta) { } + public void OnEntity(in RuntimeEntityDelta delta) { } + public void OnInventory(in RuntimeInventoryDelta delta) { } + public void OnChat(in RuntimeChatDelta delta) => Chat.Add(delta); + public void OnMovement(in RuntimeMovementDelta delta) { } + public void OnPortal(in RuntimePortalDelta delta) { } + } + + private sealed class InjectedConstructionFailure : Exception + { + } +}