Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
The sole VendorState joins the J4.2 inventory owners: populated by the new 0x0062 ApproachVendor route (parse via VendorApproach, wire-to- domain mapping at the routing seam, silent-drop on malformed like every sibling), borrowed by both graphical and headless hosts, and torn down through the EXISTING ExternalContainer reset stage — session reset, portal-out, and logout all funnel through the one mechanism. Close is client-local per retail (nothing on the wire): a range watcher rides the existing per-advanced-frame publishMovement callback, using the vendor's own authored UseRadius (ACE's 0.6 m fallback when absent). The dormant ItemInteractionController ActiveVendorId seam is finally wired as a live delegate — real id while open, 0 the moment the session clears. AP-160 filed in this same commit: the watcher measures plain 3D center distance rather than retail's cylinder-gap, because Runtime has no per-NPC collision radius/height source; bounded sub-meter, client- local UI only. Twelve Runtime tests: populate/field mapping, vendor replacement, range clear + within-range retention, all three generation teardowns, the ActiveVendorId seam, malformed-event drop. Clean-room complete solution: 11,302 passed / 4 skipped / 0 failed. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
739 lines
27 KiB
C#
739 lines
27 KiB
C#
using System.Numerics;
|
|
using AcDream.Runtime.Entities;
|
|
using AcDream.Runtime.Gameplay;
|
|
using AcDream.Runtime.Physics;
|
|
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,
|
|
RuntimeGenerationResetSnapshot GenerationReset,
|
|
GameRuntimeEventOwnershipSnapshot Events)
|
|
{
|
|
public bool IsConverged =>
|
|
IsDisposed
|
|
&& IsDisposeRequested
|
|
&& !IsDisposeDrainActive
|
|
&& HostLeaseCount == 0
|
|
&& CompletedTeardownStages == GameRuntimeTeardownStage.Complete
|
|
&& Session.IsConverged
|
|
&& PlayerIdentity.IsConverged
|
|
&& Simulation.IsConverged
|
|
&& Transit.IsSessionIdle
|
|
&& GenerationReset.IsConverged
|
|
&& 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,
|
|
dependencies.TimeProvider,
|
|
clock);
|
|
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);
|
|
|
|
var generationReset = new RuntimeGenerationReset(
|
|
transit,
|
|
context.Communication,
|
|
context.Inventory,
|
|
context.Actions,
|
|
context.Movement,
|
|
context.EntityObjects,
|
|
context.Character,
|
|
context.PlayerIdentity);
|
|
|
|
context.Movement.AttachPhysicsPublication(
|
|
new RuntimeLocalPlayerPhysicsPublicationState(
|
|
context.EntityObjects.Entities,
|
|
context.EntityObjects.Physics,
|
|
context.Movement,
|
|
context.PlayerIdentity));
|
|
// C3c: the C3a conductor's "first act" — bind the publication
|
|
// owner the conductor was constructed without (it is built by
|
|
// RuntimeEntityObjectLifetime BEFORE
|
|
// RuntimeLocalPlayerPhysicsPublicationState exists; see the F2
|
|
// late-bind note on RuntimeLocalPlayerFirstEntryState's ctor).
|
|
context.EntityObjects.LocalPlayerFirstEntry.BindPublication(
|
|
context.Movement.PhysicsPublication);
|
|
|
|
context.EntityObjects.BindEventContext(
|
|
() => generationReset.ActiveRetiringGeneration
|
|
?? context.Session.Generation,
|
|
() => clock.FrameNumber);
|
|
// C0-2: bind the executor's live-input sources to the real
|
|
// Runtime owners now that both exist (RuntimeCharacterState at
|
|
// CharacterCreated, RuntimeLocalPlayerMovementState at
|
|
// MovementCreated - both after EntityObjectsCreated, so this
|
|
// cannot move earlier). UsePositionFromServer mirrors retail
|
|
// CommandInterpreter::UsePositionFromServer exactly; PlayerDistance
|
|
// is derived per Execute call from the live physics-controller
|
|
// position, matching the legacy remote path's own distance basis
|
|
// (LiveEntityNetworkUpdateController's MaxPhysicsDistance/dist).
|
|
// F3: the position source is nullable - a null Controller (the
|
|
// login-window drain, before the local player's own controller
|
|
// exists yet) must yield null, never a fabricated Vector3.Zero
|
|
// that would misclassify every remote entity as implausibly far.
|
|
context.EntityObjects.BindLiveInputs(
|
|
() => context.Character.UsePositionFromServer,
|
|
() => context.Movement.Controller?.Position);
|
|
context.Events = new GameRuntimeEventHub(
|
|
context.EntityObjects,
|
|
context.Communication,
|
|
context.Actions);
|
|
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;
|
|
GenerationReset = generationReset;
|
|
_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; }
|
|
internal RuntimeLocalPlayerPhysicsPublicationState
|
|
LocalPlayerPhysicsPublication => MovementOwner.PhysicsPublication;
|
|
public RuntimeWorldEnvironmentState EnvironmentOwner { get; }
|
|
public RuntimeWorldTransitState TransitOwner { get; }
|
|
public RuntimeGenerationReset GenerationReset { get; }
|
|
public RuntimePlacementProjectionChannel Placements =>
|
|
EntityObjects.Placements;
|
|
|
|
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 RuntimeLocalPlayerFrameController CreateLocalPlayerFrameController(
|
|
IRuntimeLocalPlayerFrameHost host,
|
|
IRuntimeMovementInputSource input)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposeRequested || _disposed, this);
|
|
return new RuntimeLocalPlayerFrameController(
|
|
host,
|
|
input,
|
|
() =>
|
|
{
|
|
_events.EmitMovement(MovementOwner.Snapshot);
|
|
// Slice 5.3: the local-player movement publish already fires
|
|
// once per advanced frame for both graphical and no-window
|
|
// hosts (RuntimeLocalPlayerFrameController.RunPostNetworkCommandPhase),
|
|
// so the client-local vendor distance watcher piggybacks on
|
|
// it instead of adding a second polling loop.
|
|
RuntimeVendorRangeQuery.EnforceRange(this);
|
|
});
|
|
}
|
|
|
|
public void ResetGeneration(
|
|
RuntimeGenerationToken retiringGeneration,
|
|
IRuntimeGenerationResetHost host) =>
|
|
GenerationReset.Reset(retiringGeneration, host);
|
|
|
|
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(),
|
|
GenerationReset.CaptureSnapshot(),
|
|
_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:
|
|
GenerationReset.DrainPending();
|
|
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);
|
|
}
|
|
}
|
|
}
|