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