refactor(runtime): compose canonical game runtime root

This commit is contained in:
Erik 2026-07-26 18:51:17 +02:00
parent 75f9510e10
commit 96ddd16539
6 changed files with 1585 additions and 0 deletions

View file

@ -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 FL, 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 <sha>` 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.

View file

@ -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<string>? Log = null,
Action<string>? TimeSyncDiagnostic = null,
ILiveSessionOperations? SessionOperations = null,
Func<double>? 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; }
}
/// <summary>
/// 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.
/// </summary>
public sealed class GameRuntime
: IGameRuntimeView,
IRuntimeEventSource,
IDisposable
{
private const int TeardownStageCount = 11;
private readonly object _lifetimeGate = new();
private readonly Dictionary<long, string> _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<GameRuntimeConstructionPoint, GameRuntimeConstructionContext>?
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());
}
}
/// <summary>
/// Makes transport and all per-generation routes inert while retaining the
/// root for ordered host projection teardown.
/// </summary>
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<Exception>? 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<GameRuntimeConstructionPoint, GameRuntimeConstructionContext>?
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<IDisposable> _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<Exception> { 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);
}
}
}

View file

@ -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);
}
/// <summary>
/// Lazy ordered event projection over one exact Runtime owner graph. With no
/// observers it attaches no owner callbacks and adds no production event work.
/// </summary>
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<Exception>? 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<Exception>? 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<Exception>? 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);
}
}

View file

@ -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;
}
/// <summary>
/// Canonical session-scoped identity of the local player. Graphical and direct
/// hosts borrow this exact owner; presentation adapters may not mirror it.
/// </summary>
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;
}
}

View file

@ -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)

View file

@ -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<InvalidOperationException>(
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<InjectedConstructionFailure>(() =>
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<ClientObject> 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<RuntimeChatDelta> 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
{
}
}