refactor(runtime): cut graphical host over to canonical root

Make every App composition phase borrow one GameRuntime, retire the duplicate view/event adapters, and dispose the root only after its graphical borrowers release. This preserves synchronous UI commands while giving shutdown one exact ownership ledger.

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-26 19:06:09 +02:00
parent ecb9f79444
commit ce41efb9e5
29 changed files with 613 additions and 778 deletions

View file

@ -1,10 +1,6 @@
using AcDream.App.Input;
using AcDream.App.Interaction;
using AcDream.App.Net;
using AcDream.App.World;
using AcDream.Runtime;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Session;
using AcDream.Runtime.World;
using AcDream.UI.Abstractions;
@ -12,9 +8,9 @@ using AcDream.UI.Abstractions;
namespace AcDream.App.Runtime;
/// <summary>
/// App composition root for Slice J's borrowed runtime boundary. Focused
/// adapters project the existing canonical owners; this root owns only those
/// adapters and never owns or copies gameplay state.
/// Synchronous graphical command adapter over one canonical
/// <see cref="GameRuntime"/>. It owns only its host lease and observer
/// subscriptions; every view and mutable owner is borrowed directly.
/// </summary>
internal sealed class CurrentGameRuntimeAdapter
: IGameRuntimeView,
@ -22,72 +18,80 @@ internal sealed class CurrentGameRuntimeAdapter
IRuntimeEventSource,
IDisposable
{
private readonly CurrentGameRuntimeViewAdapter _view;
private readonly CurrentGameRuntimeEventAdapter _events;
private readonly GameRuntime _runtime;
private readonly CurrentGameRuntimeCommandAdapter _commands;
private readonly IDisposable _hostLease;
private readonly object _subscriptionGate = new();
private readonly HashSet<AdapterSubscription> _subscriptions = [];
private bool _disposed;
public CurrentGameRuntimeAdapter(
LiveSessionController session,
GameRuntime runtime,
LiveSessionHost sessionHost,
ICommandBus commands,
LocalPlayerIdentityState playerIdentity,
RuntimeEntityObjectLifetime entityObjects,
RuntimeInventoryState inventory,
RuntimeCharacterState character,
RuntimeCommunicationState communication,
RuntimeActionState actions,
RuntimeLocalPlayerMovementState movement,
RuntimeWorldEnvironmentState environment,
RuntimeWorldTransitState worldTransit,
IGameRuntimeClock clock,
SelectionInteractionController selection)
{
_view = new CurrentGameRuntimeViewAdapter(
session,
playerIdentity,
entityObjects,
inventory,
character,
communication,
actions,
movement,
environment,
worldTransit,
clock);
entityObjects.BindEventContext(
() => _view.Generation,
() => clock.FrameNumber);
_events = new CurrentGameRuntimeEventAdapter(
_view,
entityObjects,
communication);
_commands = new CurrentGameRuntimeCommandAdapter(
session,
sessionHost,
commands,
_view,
inventory,
character,
actions,
movement,
selection,
_events);
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
ArgumentNullException.ThrowIfNull(sessionHost);
ArgumentNullException.ThrowIfNull(commands);
ArgumentNullException.ThrowIfNull(selection);
_hostLease = runtime.AcquireHostLease(
"graphical game-runtime command adapter");
try
{
_commands = new CurrentGameRuntimeCommandAdapter(
runtime.Session,
sessionHost,
commands,
this,
runtime.InventoryOwner,
runtime.CharacterOwner,
runtime.ActionOwner,
runtime.MovementOwner,
selection,
runtime.EventSink);
}
catch
{
_hostLease.Dispose();
throw;
}
}
public RuntimeGenerationToken Generation => _view.Generation;
public RuntimeLifecycleSnapshot Lifecycle => _view.Lifecycle;
public IGameRuntimeClock Clock => _view.Clock;
public IRuntimeEntityView Entities => _view.Entities;
public IRuntimeInventoryView Inventory => _view.Inventory;
public IRuntimeInventoryStateView InventoryState => _view.InventoryState;
public IRuntimeCharacterView Character => _view.Character;
public IRuntimeSocialView Social => _view.Social;
public IRuntimeChatView Chat => _view.Chat;
public IRuntimeActionView Actions => _view.Actions;
public IRuntimeMovementView Movement => _view.Movement;
public IRuntimeWorldEnvironmentView Environment => _view.Environment;
public IRuntimePortalView Portal => _view.Portal;
private bool IsActive =>
!_disposed
&& !_runtime.Session.IsDisposalComplete;
public RuntimeGenerationToken Generation => _runtime.Generation;
public RuntimeLifecycleSnapshot Lifecycle
{
get
{
RuntimeLifecycleSnapshot current = _runtime.Lifecycle;
return IsActive
? current
: current with
{
State = RuntimeLifecycleState.Disposed,
HasTransport = false,
};
}
}
public IGameRuntimeClock Clock => _runtime.Clock;
public IRuntimeEntityView Entities => _runtime.Entities;
public IRuntimeInventoryView Inventory => _runtime.Inventory;
public IRuntimeInventoryStateView InventoryState =>
_runtime.InventoryState;
public IRuntimeCharacterView Character => _runtime.Character;
public IRuntimeSocialView Social => _runtime.Social;
public IRuntimeChatView Chat => _runtime.Chat;
public IRuntimeActionView Actions => _runtime.Actions;
public IRuntimeMovementView Movement => _runtime.Movement;
public IRuntimeWorldEnvironmentView Environment => _runtime.Environment;
public IRuntimePortalView Portal => _runtime.Portal;
public IRuntimeSessionCommands Session => _commands;
public IRuntimeSelectionCommands Selection => _commands;
@ -109,18 +113,64 @@ internal sealed class CurrentGameRuntimeAdapter
public IRuntimeSocialCommands SocialCommands => _commands;
IRuntimeSocialCommands IGameRuntimeCommands.Social => _commands;
public RuntimeStateCheckpoint CaptureCheckpoint() =>
_view.CaptureCheckpoint();
public RuntimeStateCheckpoint CaptureCheckpoint()
{
RuntimeStateCheckpoint checkpoint = _runtime.CaptureCheckpoint();
return checkpoint with { Lifecycle = Lifecycle.State };
}
public IDisposable Subscribe(IRuntimeEventObserver observer) =>
_events.Subscribe(observer);
public IDisposable Subscribe(IRuntimeEventObserver observer)
{
ArgumentNullException.ThrowIfNull(observer);
lock (_subscriptionGate)
{
ObjectDisposedException.ThrowIf(_disposed, this);
IDisposable runtimeSubscription = _runtime.Subscribe(observer);
var subscription = new AdapterSubscription(
this,
runtimeSubscription);
_subscriptions.Add(subscription);
return subscription;
}
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
_view.Deactivate();
_events.Dispose();
lock (_subscriptionGate)
{
if (_disposed)
return;
_disposed = true;
while (_subscriptions.Count != 0)
_subscriptions.First().DisposeCore();
}
_hostLease.Dispose();
}
private void Remove(AdapterSubscription subscription)
{
lock (_subscriptionGate)
_subscriptions.Remove(subscription);
}
private sealed class AdapterSubscription(
CurrentGameRuntimeAdapter owner,
IDisposable runtimeSubscription) : IDisposable
{
private CurrentGameRuntimeAdapter? _owner = owner;
private IDisposable? _runtimeSubscription = runtimeSubscription;
public void Dispose() => DisposeCore();
public void DisposeCore()
{
IDisposable? runtime = Interlocked.Exchange(
ref _runtimeSubscription,
null);
if (runtime is null)
return;
runtime.Dispose();
Interlocked.Exchange(ref _owner, null)?.Remove(this);
}
}
}

View file

@ -30,25 +30,25 @@ internal sealed class CurrentGameRuntimeCommandAdapter
private readonly LiveSessionController _session;
private readonly LiveSessionHost _sessionHost;
private readonly ICommandBus _commands;
private readonly CurrentGameRuntimeViewAdapter _view;
private readonly IGameRuntimeView _view;
private readonly RuntimeInventoryState _inventory;
private readonly RuntimeCharacterState _character;
private readonly RuntimeActionState _actions;
private readonly RuntimeLocalPlayerMovementState _movement;
private readonly SelectionInteractionController _selection;
private readonly ICurrentGameRuntimeEventSink _events;
private readonly IGameRuntimeEventSink _events;
public CurrentGameRuntimeCommandAdapter(
LiveSessionController session,
LiveSessionHost sessionHost,
ICommandBus commands,
CurrentGameRuntimeViewAdapter view,
IGameRuntimeView view,
RuntimeInventoryState inventory,
RuntimeCharacterState character,
RuntimeActionState actions,
RuntimeLocalPlayerMovementState movement,
SelectionInteractionController selection,
ICurrentGameRuntimeEventSink events)
IGameRuntimeEventSink events)
{
_session = session ?? throw new ArgumentNullException(nameof(session));
_sessionHost = sessionHost ?? throw new ArgumentNullException(nameof(sessionHost));
@ -80,7 +80,7 @@ internal sealed class CurrentGameRuntimeCommandAdapter
ToCommandStatus(result.Status),
result.CharacterId,
result.CharacterName);
_events.EmitLifecycle(previous);
_events.EmitLifecycle(previous, _view.Lifecycle.State);
return result;
}
@ -100,7 +100,7 @@ internal sealed class CurrentGameRuntimeCommandAdapter
ToCommandStatus(result.Status),
result.CharacterId,
result.CharacterName);
_events.EmitLifecycle(previous);
_events.EmitLifecycle(previous, _view.Lifecycle.State);
return result;
}
@ -125,7 +125,7 @@ internal sealed class CurrentGameRuntimeCommandAdapter
operation: 2,
acknowledgement.Status,
text: acknowledgement.Error?.GetType().Name ?? string.Empty);
_events.EmitLifecycle(previous);
_events.EmitLifecycle(previous, _view.Lifecycle.State);
return acknowledgement;
}
@ -674,7 +674,7 @@ internal sealed class CurrentGameRuntimeCommandAdapter
RuntimeGenerationToken expectedGeneration,
bool requireWorld)
{
if (!_view.IsActive)
if (_view.Lifecycle.State == RuntimeLifecycleState.Disposed)
return RuntimeCommandStatus.Inactive;
if (expectedGeneration != _view.Generation)
return RuntimeCommandStatus.StaleGeneration;

View file

@ -1,275 +0,0 @@
using AcDream.Runtime;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Gameplay;
namespace AcDream.App.Runtime;
internal interface ICurrentGameRuntimeEventSink
{
void EmitCommand(
RuntimeCommandDomain domain,
int operation,
RuntimeCommandStatus status,
uint primaryObjectId = 0u,
string? text = null);
void EmitLifecycle(RuntimeLifecycleState previous);
}
/// <summary>
/// Lazily projects existing owner notifications into one ordered diagnostic
/// stream. With no subscribers it attaches no callbacks and adds no per-event
/// production work.
/// </summary>
internal sealed class CurrentGameRuntimeEventAdapter
: IRuntimeEventSource,
ICurrentGameRuntimeEventSink,
IRuntimeEntityObjectObserver,
IRuntimeCommunicationObserver,
IDisposable
{
private readonly CurrentGameRuntimeViewAdapter _view;
private readonly RuntimeEntityObjectLifetime _entityObjects;
private readonly RuntimeCommunicationState _communication;
private readonly object _observerGate = new();
private IRuntimeEventObserver[] _observers = [];
private IDisposable? _entityObjectSubscription;
private IDisposable? _communicationSubscription;
private bool _ownerEventsAttached;
private bool _disposed;
internal long DispatchFailureCount { get; private set; }
internal Exception? LastDispatchFailure { get; private set; }
public CurrentGameRuntimeEventAdapter(
CurrentGameRuntimeViewAdapter view,
RuntimeEntityObjectLifetime entityObjects,
RuntimeCommunicationState communication)
{
_view = view ?? throw new ArgumentNullException(nameof(view));
_entityObjects = entityObjects
?? throw new ArgumentNullException(nameof(entityObjects));
_communication = communication
?? throw new ArgumentNullException(nameof(communication));
}
public IDisposable Subscribe(IRuntimeEventObserver observer)
{
ArgumentNullException.ThrowIfNull(observer);
lock (_observerGate)
{
ObjectDisposedException.ThrowIf(_disposed, this);
IRuntimeEventObserver[] current = _observers;
if (Array.IndexOf(current, observer) >= 0)
{
throw new InvalidOperationException(
"The runtime observer is already subscribed.");
}
var replacement = new IRuntimeEventObserver[current.Length + 1];
Array.Copy(current, replacement, current.Length);
replacement[^1] = observer;
Volatile.Write(ref _observers, replacement);
if (!_ownerEventsAttached)
AttachOwnerEvents();
}
return new ObserverSubscription(this, observer);
}
public void Dispose()
{
lock (_observerGate)
{
if (_disposed)
return;
_disposed = true;
Volatile.Write(ref _observers, []);
DetachOwnerEvents();
}
}
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 = _view.Lifecycle.State;
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);
}
}
}
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 AttachOwnerEvents()
{
_entityObjectSubscription = _entityObjects.Events.Subscribe(this);
_communicationSubscription = _communication.Events.Subscribe(this);
_ownerEventsAttached = true;
}
private void DetachOwnerEvents()
{
if (!_ownerEventsAttached)
return;
_communicationSubscription?.Dispose();
_communicationSubscription = null;
_entityObjectSubscription?.Dispose();
_entityObjectSubscription = null;
_ownerEventsAttached = false;
}
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 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(
CurrentGameRuntimeEventAdapter owner,
IRuntimeEventObserver observer)
: IDisposable
{
private CurrentGameRuntimeEventAdapter? _owner = owner;
public void Dispose() =>
Interlocked.Exchange(ref _owner, null)?.Unsubscribe(observer);
}
}

View file

@ -1,134 +0,0 @@
using AcDream.App.Input;
using AcDream.App.Net;
using AcDream.Core.Chat;
using AcDream.Core.Items;
using AcDream.Runtime;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Session;
using AcDream.Runtime.World;
namespace AcDream.App.Runtime;
/// <summary>
/// Read-only borrowed projections over the current App owners. Enumeration is
/// synchronous and allocation-free; no mutable collection is copied or owned.
/// </summary>
internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView
{
private readonly LiveSessionController _session;
private readonly LocalPlayerIdentityState _playerIdentity;
private readonly IGameRuntimeClock _clock;
private readonly IRuntimeEntityView _entityView;
private readonly IRuntimeInventoryView _inventoryView;
private readonly IRuntimeInventoryStateView _inventoryStateView;
private readonly IRuntimeCharacterView _characterView;
private readonly IRuntimeSocialView _socialView;
private readonly IRuntimeChatView _chatView;
private readonly IRuntimeActionView _actionView;
private readonly IRuntimeMovementView _movementView;
private readonly IRuntimeWorldEnvironmentView _environmentView;
private readonly IRuntimePortalView _portalView;
private bool _active = true;
public CurrentGameRuntimeViewAdapter(
LiveSessionController session,
LocalPlayerIdentityState playerIdentity,
RuntimeEntityObjectLifetime entityObjects,
RuntimeInventoryState inventory,
RuntimeCharacterState character,
RuntimeCommunicationState communication,
RuntimeActionState actions,
RuntimeLocalPlayerMovementState movement,
RuntimeWorldEnvironmentState environment,
RuntimeWorldTransitState worldTransit,
IGameRuntimeClock clock)
{
_session = session ?? throw new ArgumentNullException(nameof(session));
_playerIdentity = playerIdentity
?? throw new ArgumentNullException(nameof(playerIdentity));
ArgumentNullException.ThrowIfNull(entityObjects);
ArgumentNullException.ThrowIfNull(communication);
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
_entityView = entityObjects.EntityView;
_inventoryView = entityObjects.InventoryView;
_inventoryStateView = (
inventory ?? throw new ArgumentNullException(nameof(inventory))).View;
_characterView = (
character ?? throw new ArgumentNullException(nameof(character))).View;
_chatView = communication.View;
_socialView = communication.SocialView;
_actionView = (
actions ?? throw new ArgumentNullException(nameof(actions))).View;
_movementView = (
movement ?? throw new ArgumentNullException(nameof(movement))).View;
_environmentView = environment
?? throw new ArgumentNullException(nameof(environment));
_portalView = worldTransit
?? throw new ArgumentNullException(nameof(worldTransit));
}
internal bool IsActive => _active && !_session.IsDisposalComplete;
public RuntimeGenerationToken Generation => _session.Generation;
public RuntimeLifecycleSnapshot Lifecycle
{
get
{
RuntimeLifecycleState state;
if (!IsActive)
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);
}
}
public IGameRuntimeClock Clock => _clock;
public IRuntimeEntityView Entities => _entityView;
public IRuntimeInventoryView Inventory => _inventoryView;
public IRuntimeInventoryStateView InventoryState => _inventoryStateView;
public IRuntimeCharacterView Character => _characterView;
public IRuntimeSocialView Social => _socialView;
public IRuntimeChatView Chat => _chatView;
public IRuntimeActionView Actions => _actionView;
public IRuntimeMovementView Movement => _movementView;
public IRuntimeWorldEnvironmentView Environment => _environmentView;
public IRuntimePortalView Portal => _portalView;
public RuntimeStateCheckpoint CaptureCheckpoint() =>
new(
Generation,
Lifecycle.State,
_clock.FrameNumber,
_entityView.Count,
_entityView.MaterializedCount,
_inventoryView.ObjectCount,
_inventoryView.ContainerCount,
_inventoryStateView.Snapshot,
_characterView.Snapshot,
_socialView.Snapshot,
_chatView.Revision,
_chatView.Count,
_actionView.Snapshot,
_movementView.Snapshot,
_environmentView.Snapshot,
_environmentView.Ownership,
_portalView.Snapshot,
_portalView.Ownership);
internal void Deactivate() => _active = false;
}