refactor(runtime): compose canonical game runtime root
This commit is contained in:
parent
75f9510e10
commit
96ddd16539
6 changed files with 1585 additions and 0 deletions
165
docs/plans/2026-07-26-modern-runtime-slice-j7.md
Normal file
165
docs/plans/2026-07-26-modern-runtime-slice-j7.md
Normal 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 F–L, including the J gameplay-owner
|
||||||
|
gates, on 2026-07-24
|
||||||
|
**Purpose:** compose the already-proven Runtime lifetime groups behind one
|
||||||
|
instance-scoped owner without changing retail behavior, update order, command
|
||||||
|
latency, rendering, retained UI, or portal presentation.
|
||||||
|
|
||||||
|
## 1. Baseline problem
|
||||||
|
|
||||||
|
The canonical state is already in `AcDream.Runtime`, but production constructs
|
||||||
|
its owners at separate graphical composition points:
|
||||||
|
|
||||||
|
| Owner | Baseline construction site |
|
||||||
|
|---|---|
|
||||||
|
| entity/object/physics lifetime | `GameWindow` field initializer |
|
||||||
|
| inventory, character, communication, actions | `GameWindow` constructor |
|
||||||
|
| local movement | `GameWindow` field initializer |
|
||||||
|
| world environment | `GameWindow` through `WorldEnvironmentController` |
|
||||||
|
| world transit/reveal | `LivePresentationComposition` |
|
||||||
|
| session/transport | `SessionPlayerComposition` |
|
||||||
|
| simulation clock | inside App's `UpdateFrameClock` |
|
||||||
|
| normalized view/events | App `CurrentGameRuntime*Adapter` objects |
|
||||||
|
|
||||||
|
These are not duplicate gameplay models, but the split construction and
|
||||||
|
teardown graph makes it possible to assemble incompatible owners or retire
|
||||||
|
them in the wrong order. It also prevents a no-window host from constructing
|
||||||
|
the exact same complete kernel in one operation.
|
||||||
|
|
||||||
|
## 2. Fixed boundary
|
||||||
|
|
||||||
|
Add one public, presentation-independent `GameRuntime` in
|
||||||
|
`AcDream.Runtime`. It owns exactly:
|
||||||
|
|
||||||
|
- canonical session/transport lifetime;
|
||||||
|
- local player server identity;
|
||||||
|
- entity/object/physics lifetime;
|
||||||
|
- inventory, character, communication, and action lifetime groups;
|
||||||
|
- local movement intent/controller lifetime;
|
||||||
|
- world environment and world transit/reveal state;
|
||||||
|
- the instance simulation clock;
|
||||||
|
- normalized borrowed views and the lazy ordered Runtime event source;
|
||||||
|
- the terminal ownership ledger and retryable teardown cursor.
|
||||||
|
|
||||||
|
`GameRuntime` may depend only on Runtime's existing operation interfaces,
|
||||||
|
Core/Core.Net/Content/Plugin.Abstractions, and injected clock/log/session
|
||||||
|
operations. It never imports App, UI, Silk.NET, OpenAL, Arch, or ImGui.
|
||||||
|
|
||||||
|
App keeps:
|
||||||
|
|
||||||
|
- DAT/GL/audio/UI objects;
|
||||||
|
- physical input, camera, selection presentation, and command translation;
|
||||||
|
- render-space origin and portal tunnel presentation;
|
||||||
|
- synchronous adapters that borrow one `GameRuntime`;
|
||||||
|
- generation-scoped graphical resource receipts.
|
||||||
|
|
||||||
|
Input, command execution, UI state reads, simulation, and rendering stay in the
|
||||||
|
same host frame. No queue, snapshot copy, worker hop, or extra frame boundary is
|
||||||
|
introduced.
|
||||||
|
|
||||||
|
## 3. Ownership invariants
|
||||||
|
|
||||||
|
1. A production `GameWindow` stores exactly one `GameRuntime` field.
|
||||||
|
2. Composition dependency records receive that root, not independently paired
|
||||||
|
Runtime owners.
|
||||||
|
3. Runtime children are created once by the root and are never replaced.
|
||||||
|
4. Graphical adapters may borrow children but cannot dispose or rebind them.
|
||||||
|
5. The session is quiesced by the root before App route/projection teardown.
|
||||||
|
6. App projection owners retire before the Runtime root's terminal disposal.
|
||||||
|
7. Root disposal is ordered, retryable, and resumes at the exact failed suffix.
|
||||||
|
8. Construction failure retires every already-created child in reverse order.
|
||||||
|
9. The event source attaches to owner events only while observers exist and is
|
||||||
|
detached before owner disposal.
|
||||||
|
10. A disposed graphical adapter is inert without disposing the Runtime root.
|
||||||
|
11. Two roots never share identity, clock, event sequence, world, gameplay,
|
||||||
|
physics, or session state.
|
||||||
|
12. Root construction and direct views load no graphical/audio/UI backend.
|
||||||
|
|
||||||
|
## 4. Execution
|
||||||
|
|
||||||
|
### J7.1 — Runtime root, identity, events, and terminal ledger
|
||||||
|
|
||||||
|
- Add `GameRuntime`, typed construction dependencies, local-player identity,
|
||||||
|
construction fault points, ownership snapshot, and exact teardown stages.
|
||||||
|
- Move the normalized view/checkpoint implementation into the root.
|
||||||
|
- Move the lazy entity/inventory/chat event fanout into Runtime.
|
||||||
|
- Add session ownership accounting.
|
||||||
|
- Prove reverse construction rollback, exact retryable disposal, two-instance
|
||||||
|
isolation, lazy subscriptions, and zero presentation dependencies.
|
||||||
|
|
||||||
|
Gate: focused Runtime tests pass and dependency/source guards find no App or
|
||||||
|
backend reference.
|
||||||
|
|
||||||
|
### J7.2 — production composition cutover
|
||||||
|
|
||||||
|
- Construct one root in `GameWindow`.
|
||||||
|
- Make `UpdateFrameClock` borrow the root clock.
|
||||||
|
- Make `WorldEnvironmentController` and `WorldGenerationAvailabilityState`
|
||||||
|
graphical adapters over root children.
|
||||||
|
- Make live presentation borrow root transit rather than construct it.
|
||||||
|
- Make session composition borrow root session rather than construct/transfer
|
||||||
|
it.
|
||||||
|
- Change composition dependency records to carry the root instead of
|
||||||
|
independent Runtime owners.
|
||||||
|
- Replace App's view/event reconstruction with one inertable graphical command
|
||||||
|
adapter over the root.
|
||||||
|
- Delete superseded App view/event adapters and direct production constructors.
|
||||||
|
|
||||||
|
Gate: App composition/failure-injection tests prove one exact root and no
|
||||||
|
partial publication or borrowed-owner disposal.
|
||||||
|
|
||||||
|
### J7.3 — one shutdown transaction
|
||||||
|
|
||||||
|
- Replace individual Runtime roots in `GameWindowShutdownRoots` with
|
||||||
|
`GameRuntime`.
|
||||||
|
- Quiesce its session at the existing ingress barrier.
|
||||||
|
- Retire every graphical borrower before terminal root disposal.
|
||||||
|
- Make root disposal preserve the exact suffix through callback failure and
|
||||||
|
re-entrancy.
|
||||||
|
- Extend checkpoint/terminal ledgers with root/session/event ownership.
|
||||||
|
|
||||||
|
Gate: every shutdown fault point converges on retry; no subscriber, route,
|
||||||
|
entity, physics, gameplay, transit, or session debt remains.
|
||||||
|
|
||||||
|
### J7.4 — validation and closeout
|
||||||
|
|
||||||
|
- Focused Runtime and App tests.
|
||||||
|
- Release solution build.
|
||||||
|
- Complete Release suite.
|
||||||
|
- Exact-binary connected lifecycle/reconnect route.
|
||||||
|
- Canonical nine-stop route and allocation/frame-time comparison against J6.
|
||||||
|
- Physical-display visual checks for login, world objects, radar, inventory
|
||||||
|
paperdoll, use/approach, combat facing, portal tunnel, and portal exit.
|
||||||
|
- Update architecture, code structure, milestone, roadmap, parent/umbrella
|
||||||
|
plans, AGENTS/CLAUDE, divergence ledger, and durable memory.
|
||||||
|
|
||||||
|
## 5. Adversarial matrix
|
||||||
|
|
||||||
|
- construction failure after every child owner;
|
||||||
|
- disposal failure at every root teardown stage;
|
||||||
|
- re-entrant session stop/dispose;
|
||||||
|
- observer removal and addition during event dispatch;
|
||||||
|
- observer failure while another observer remains;
|
||||||
|
- App adapter disposal while Runtime remains live;
|
||||||
|
- root disposal while an App borrower is still registered;
|
||||||
|
- session start/reconnect during disposal;
|
||||||
|
- duplicate disposal and retry after partial teardown;
|
||||||
|
- two simultaneous roots with different clocks, identities, entities, portals,
|
||||||
|
and event observers;
|
||||||
|
- portal host acknowledgement pending at shutdown;
|
||||||
|
- graphical composition failure before and after session host creation;
|
||||||
|
- same-frame selection/use/combat/magic/UI response before and after cutover;
|
||||||
|
- no Runtime load of App/UI/Silk/OpenAL/Arch/ImGui.
|
||||||
|
|
||||||
|
## 6. Commit and rollback discipline
|
||||||
|
|
||||||
|
Each production sub-slice lands separately after its focused and complete
|
||||||
|
Release gates. Record its full production SHA and exact `git revert <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.
|
||||||
658
src/AcDream.Runtime/GameRuntime.cs
Normal file
658
src/AcDream.Runtime/GameRuntime.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
403
src/AcDream.Runtime/GameRuntimeEventHub.cs
Normal file
403
src/AcDream.Runtime/GameRuntimeEventHub.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,30 @@ public enum LiveSessionStartStatus
|
||||||
Failed,
|
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(
|
public sealed record LiveSessionCharacterSelection(
|
||||||
int ActiveIndex,
|
int ActiveIndex,
|
||||||
uint CharacterId,
|
uint CharacterId,
|
||||||
|
|
@ -272,6 +296,24 @@ public sealed class LiveSessionController
|
||||||
get { lock (_gate) return _disposed; }
|
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(
|
public LiveSessionStartResult Start(
|
||||||
LiveSessionConnectOptions options,
|
LiveSessionConnectOptions options,
|
||||||
ILiveSessionLifecycleHost host)
|
ILiveSessionLifecycleHost host)
|
||||||
|
|
|
||||||
263
tests/AcDream.Runtime.Tests/GameRuntimeTests.cs
Normal file
263
tests/AcDream.Runtime.Tests/GameRuntimeTests.cs
Normal 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
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue