refactor(runtime): unify generation reset for direct hosts
Move canonical per-session teardown into one retryable Runtime transaction, reduce App reset to projection acknowledgements, and prove the same GameRuntime graph through deterministic no-window lifecycle, gameplay, portal, fault, reconnect, and isolation gates.\n\nCo-authored-by: Codex <noreply@openai.com>
This commit is contained in:
parent
7818494116
commit
a9a822f206
28 changed files with 2707 additions and 345 deletions
|
|
@ -815,6 +815,7 @@ internal sealed class SessionPlayerCompositionPhase
|
||||||
d.PlayerController,
|
d.PlayerController,
|
||||||
d.WorldOrigin),
|
d.WorldOrigin),
|
||||||
new LiveSessionDomainRuntime(
|
new LiveSessionDomainRuntime(
|
||||||
|
d.Runtime,
|
||||||
d.EntityObjects,
|
d.EntityObjects,
|
||||||
d.Character,
|
d.Character,
|
||||||
d.Actions,
|
d.Actions,
|
||||||
|
|
|
||||||
|
|
@ -476,6 +476,29 @@ internal sealed class SelectionInteractionController
|
||||||
failures);
|
failures);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retires only App approach and retained item-interaction presentation.
|
||||||
|
/// Runtime resets the canonical interaction and selection owners once at
|
||||||
|
/// the shared generation boundary.
|
||||||
|
/// </summary>
|
||||||
|
internal void ResetGenerationPresentation()
|
||||||
|
{
|
||||||
|
List<Exception> failures = [];
|
||||||
|
try { CancelPendingApproach(); }
|
||||||
|
catch (Exception error) { failures.Add(error); }
|
||||||
|
try { _items.ResetGenerationPresentation(); }
|
||||||
|
catch (Exception error) { failures.Add(error); }
|
||||||
|
try { _approachCompletions.Clear(); }
|
||||||
|
catch (Exception error) { failures.Add(error); }
|
||||||
|
|
||||||
|
if (failures.Count != 0)
|
||||||
|
{
|
||||||
|
throw new AggregateException(
|
||||||
|
"One or more selection-interaction presentation reset stages failed.",
|
||||||
|
failures);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private bool ValidatePickupTarget(uint serverGuid, bool showToast)
|
private bool ValidatePickupTarget(uint serverGuid, bool showToast)
|
||||||
{
|
{
|
||||||
if (_query.IsCreature(serverGuid))
|
if (_query.IsCreature(serverGuid))
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
namespace AcDream.App.Net;
|
namespace AcDream.App.Net;
|
||||||
|
|
||||||
using AcDream.App.World;
|
using AcDream.App.World;
|
||||||
|
using AcDream.Runtime;
|
||||||
|
using AcDream.Runtime.Entities;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Focused owner operations composed by the App host for one character-session
|
/// Focused owner operations composed by the App host for one character-session
|
||||||
|
|
@ -11,31 +13,18 @@ internal sealed class LiveSessionResetBindings
|
||||||
{
|
{
|
||||||
public required Action MouseCapture { get; init; }
|
public required Action MouseCapture { get; init; }
|
||||||
public required Action PlayerPresentation { get; init; }
|
public required Action PlayerPresentation { get; init; }
|
||||||
public required Action TeleportTransit { get; init; }
|
public required Action TeleportPresentation { get; init; }
|
||||||
public required Action SessionDialogs { get; init; }
|
public required Action SessionDialogs { get; init; }
|
||||||
public required Action ChatCommandTargets { get; init; }
|
|
||||||
public required Action SettingsCharacterContext { get; init; }
|
public required Action SettingsCharacterContext { get; init; }
|
||||||
public required Action EquippedChildren { get; init; }
|
public required Action EquippedChildren { get; init; }
|
||||||
public required Action ExternalContainer { get; init; }
|
public required Action InteractionPresentation { get; init; }
|
||||||
public required Action InteractionAndSelection { get; init; }
|
|
||||||
public required Action InventoryTransactions { get; init; }
|
|
||||||
public required Action SelectionPresentation { get; init; }
|
public required Action SelectionPresentation { get; init; }
|
||||||
public required Action ObjectTable { get; init; }
|
|
||||||
public required Action Spellbook { get; init; }
|
|
||||||
public required Action MagicRuntime { get; init; }
|
|
||||||
public required Action CombatAttack { get; init; }
|
|
||||||
public required Action CombatState { get; init; }
|
|
||||||
public required Action ItemMana { get; init; }
|
|
||||||
public required Action LocalPlayer { get; init; }
|
|
||||||
public required Action Friends { get; init; }
|
|
||||||
public required Action Squelch { get; init; }
|
|
||||||
public required Action TurbineChat { get; init; }
|
|
||||||
public required Action ParticleVisibility { get; init; }
|
public required Action ParticleVisibility { get; init; }
|
||||||
public required Action InboundEventFifo { get; init; }
|
public required Action InboundEventFifo { get; init; }
|
||||||
public required Action LiveLiveness { get; init; }
|
public required Action LiveLiveness { get; init; }
|
||||||
public required Action LiveRuntime { get; init; }
|
public required Action<RuntimeGenerationToken> RuntimeGeneration { get; init; }
|
||||||
public required Action RenderSceneProjection { get; init; }
|
public required Action<RuntimeGenerationToken> SessionIdentityPresentation
|
||||||
public required Action SessionIdentity { get; init; }
|
{ get; init; }
|
||||||
public required Action RemoteTeleport { get; init; }
|
public required Action RemoteTeleport { get; init; }
|
||||||
public required Action NetworkEffects { get; init; }
|
public required Action NetworkEffects { get; init; }
|
||||||
public required Action AnimationHookFrames { get; init; }
|
public required Action AnimationHookFrames { get; init; }
|
||||||
|
|
@ -59,38 +48,23 @@ internal static class LiveSessionResetManifest
|
||||||
[
|
[
|
||||||
new("mouse capture", bindings.MouseCapture),
|
new("mouse capture", bindings.MouseCapture),
|
||||||
new("player presentation", bindings.PlayerPresentation),
|
new("player presentation", bindings.PlayerPresentation),
|
||||||
new("teleport transit", bindings.TeleportTransit),
|
new("teleport presentation", bindings.TeleportPresentation),
|
||||||
new("session dialogs", bindings.SessionDialogs),
|
new("session dialogs", bindings.SessionDialogs),
|
||||||
new("chat command targets", bindings.ChatCommandTargets),
|
|
||||||
new("settings character context", bindings.SettingsCharacterContext),
|
new("settings character context", bindings.SettingsCharacterContext),
|
||||||
// Attachment projections own GL-backed registrations and must leave
|
// Attachment projections own GL-backed registrations and must leave
|
||||||
// before canonical live GUID records are released.
|
// before canonical live GUID records are released.
|
||||||
new("equipped children", bindings.EquippedChildren),
|
new("equipped children", bindings.EquippedChildren),
|
||||||
new("external container", bindings.ExternalContainer),
|
new("interaction presentation", bindings.InteractionPresentation),
|
||||||
new("interaction and selection", bindings.InteractionAndSelection),
|
|
||||||
new("inventory transactions", bindings.InventoryTransactions),
|
|
||||||
new("selection presentation", bindings.SelectionPresentation),
|
new("selection presentation", bindings.SelectionPresentation),
|
||||||
new("object table", bindings.ObjectTable),
|
|
||||||
new("spellbook", bindings.Spellbook),
|
|
||||||
new("magic runtime", bindings.MagicRuntime),
|
|
||||||
new("combat attack", bindings.CombatAttack),
|
|
||||||
new("combat state", bindings.CombatState),
|
|
||||||
new("item mana", bindings.ItemMana),
|
|
||||||
new("local player", bindings.LocalPlayer),
|
|
||||||
new("friends", bindings.Friends),
|
|
||||||
new("squelch", bindings.Squelch),
|
|
||||||
new("turbine chat", bindings.TurbineChat),
|
|
||||||
new("particle visibility", bindings.ParticleVisibility),
|
new("particle visibility", bindings.ParticleVisibility),
|
||||||
new("inbound event fifo", bindings.InboundEventFifo),
|
new("inbound event fifo", bindings.InboundEventFifo),
|
||||||
new("live liveness", bindings.LiveLiveness),
|
new("live liveness", bindings.LiveLiveness),
|
||||||
new("live runtime", bindings.LiveRuntime),
|
new("runtime generation", bindings.RuntimeGeneration),
|
||||||
// Canonical teardown appends exact projection removals. Drain them
|
|
||||||
// before identity changes so no prior-session scene record or
|
|
||||||
// journal entry can carry into the next character generation.
|
|
||||||
new("render scene projection", bindings.RenderSceneProjection),
|
|
||||||
// Identity must remain A until live teardown has converged so
|
// Identity must remain A until live teardown has converged so
|
||||||
// player-specific teardown can still classify the old record.
|
// player-specific teardown can still classify the old record.
|
||||||
new("session identity", bindings.SessionIdentity),
|
new(
|
||||||
|
"session identity presentation",
|
||||||
|
bindings.SessionIdentityPresentation),
|
||||||
new("remote teleport", bindings.RemoteTeleport),
|
new("remote teleport", bindings.RemoteTeleport),
|
||||||
// F754/F755 can precede CreateObject, so pending network effects
|
// F754/F755 can precede CreateObject, so pending network effects
|
||||||
// must clear even when no LiveEntityRecord was constructed.
|
// must clear even when no LiveEntityRecord was constructed.
|
||||||
|
|
@ -102,31 +76,33 @@ internal static class LiveSessionResetManifest
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Shared convergence gate for canonical live runtime teardown.</summary>
|
/// <summary>
|
||||||
internal static class LiveSessionEntityRuntimeReset
|
/// App's narrow borrowed projection acknowledgement for Runtime's canonical
|
||||||
|
/// generation-reset transaction. It owns no reset cursor or entity identity.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class GraphicalRuntimeGenerationResetHost
|
||||||
|
: IRuntimeGenerationResetHost
|
||||||
{
|
{
|
||||||
public static void ClearAndRequireConvergence(LiveEntityRuntime? runtime)
|
private readonly LiveEntityRuntime _entities;
|
||||||
|
private readonly Action _drainRenderProjection;
|
||||||
|
|
||||||
|
public GraphicalRuntimeGenerationResetHost(
|
||||||
|
LiveEntityRuntime entities,
|
||||||
|
Action drainRenderProjection)
|
||||||
{
|
{
|
||||||
if (runtime is null)
|
_entities = entities
|
||||||
return;
|
?? throw new ArgumentNullException(nameof(entities));
|
||||||
runtime.Clear();
|
_drainRenderProjection = drainRenderProjection
|
||||||
RequireConvergence(runtime);
|
?? throw new ArgumentNullException(nameof(drainRenderProjection));
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void RequireConvergence(LiveEntityRuntime? runtime)
|
public void RetireEntityProjection(
|
||||||
{
|
RuntimeEntityRecord entity) =>
|
||||||
if (runtime is null)
|
_entities.RetireGenerationProjection(entity);
|
||||||
return;
|
|
||||||
if (runtime.Count == 0
|
|
||||||
&& runtime.PendingTeardownCount == 0
|
|
||||||
&& runtime.MaterializedCount == 0)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new InvalidOperationException(
|
public void DrainEntityProjectionBoundary() =>
|
||||||
"The live-entity runtime has not converged after session clear " +
|
_drainRenderProjection();
|
||||||
$"(records={runtime.Count}, pending={runtime.PendingTeardownCount}, " +
|
|
||||||
$"materialized={runtime.MaterializedCount}).");
|
public void CompleteEntityProjectionRetirement() =>
|
||||||
}
|
_entities.CompleteGenerationProjectionRetirement();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,19 @@
|
||||||
namespace AcDream.App.Net;
|
namespace AcDream.App.Net;
|
||||||
|
|
||||||
/// <summary>One named owner operation in the live-session reset transaction.</summary>
|
using AcDream.Runtime;
|
||||||
internal sealed record LiveSessionResetStage(string Name, Action Reset);
|
|
||||||
|
/// <summary>One named host operation around Runtime's generation reset.</summary>
|
||||||
|
internal sealed record LiveSessionResetStage(
|
||||||
|
string Name,
|
||||||
|
Action<RuntimeGenerationToken> Reset)
|
||||||
|
{
|
||||||
|
public LiveSessionResetStage(string name, Action reset)
|
||||||
|
: this(
|
||||||
|
name,
|
||||||
|
_ => (reset ?? throw new ArgumentNullException(nameof(reset)))())
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Executes every session-state reset stage even when an earlier owner fails.
|
/// Executes every session-state reset stage even when an earlier owner fails.
|
||||||
|
|
@ -35,6 +47,9 @@ internal sealed class LiveSessionResetPlan
|
||||||
Array.ConvertAll(_stages, static stage => stage.Name);
|
Array.ConvertAll(_stages, static stage => stage.Name);
|
||||||
|
|
||||||
public void Execute()
|
public void Execute()
|
||||||
|
=> Execute(default);
|
||||||
|
|
||||||
|
public void Execute(RuntimeGenerationToken retiringGeneration)
|
||||||
{
|
{
|
||||||
if (Interlocked.Exchange(ref _executing, 1) != 0)
|
if (Interlocked.Exchange(ref _executing, 1) != 0)
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
|
|
@ -47,7 +62,7 @@ internal sealed class LiveSessionResetPlan
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
stage.Reset();
|
stage.Reset(retiringGeneration);
|
||||||
}
|
}
|
||||||
catch (Exception error)
|
catch (Exception error)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ using AcDream.Core.Social;
|
||||||
using AcDream.Core.Spells;
|
using AcDream.Core.Spells;
|
||||||
using AcDream.Core.World;
|
using AcDream.Core.World;
|
||||||
using AcDream.Content;
|
using AcDream.Content;
|
||||||
|
using AcDream.Runtime;
|
||||||
using AcDream.Runtime.Entities;
|
using AcDream.Runtime.Entities;
|
||||||
using AcDream.Runtime.Gameplay;
|
using AcDream.Runtime.Gameplay;
|
||||||
using AcDream.Runtime.Session;
|
using AcDream.Runtime.Session;
|
||||||
|
|
@ -39,6 +40,7 @@ internal sealed record LiveSessionPlayerRuntime(
|
||||||
LiveWorldOriginState WorldOrigin);
|
LiveWorldOriginState WorldOrigin);
|
||||||
|
|
||||||
internal sealed record LiveSessionDomainRuntime(
|
internal sealed record LiveSessionDomainRuntime(
|
||||||
|
GameRuntime Runtime,
|
||||||
RuntimeEntityObjectLifetime EntityObjects,
|
RuntimeEntityObjectLifetime EntityObjects,
|
||||||
RuntimeCharacterState Character,
|
RuntimeCharacterState Character,
|
||||||
RuntimeActionState Actions,
|
RuntimeActionState Actions,
|
||||||
|
|
@ -122,12 +124,18 @@ internal sealed class LiveSessionRuntimeFactory
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(controller);
|
ArgumentNullException.ThrowIfNull(controller);
|
||||||
ArgumentNullException.ThrowIfNull(connectOptions);
|
ArgumentNullException.ThrowIfNull(connectOptions);
|
||||||
|
var resetHost = new GraphicalRuntimeGenerationResetHost(
|
||||||
|
_world.LiveEntities,
|
||||||
|
() => _world.RenderSceneShadow?.DrainUpdateBoundary());
|
||||||
|
LiveSessionResetPlan reset =
|
||||||
|
LiveSessionResetManifest.Create(
|
||||||
|
CreateResetBindings(resetHost));
|
||||||
return new LiveSessionHost(controller, new LiveSessionHostBindings(
|
return new LiveSessionHost(controller, new LiveSessionHostBindings(
|
||||||
Routing: new(
|
Routing: new(
|
||||||
CreateEventRouter,
|
CreateEventRouter,
|
||||||
session => _commands.Attach(new LiveSessionCommandRouter(
|
session => _commands.Attach(new LiveSessionCommandRouter(
|
||||||
CreateCommandBindings(session)))),
|
CreateCommandBindings(session)))),
|
||||||
Reset: LiveSessionResetManifest.Create(CreateResetBindings()).Execute,
|
Reset: reset.Execute,
|
||||||
Selection: new(
|
Selection: new(
|
||||||
SetPlayerIdentity: id => _player.Identity.ServerGuid = id,
|
SetPlayerIdentity: id => _player.Identity.ServerGuid = id,
|
||||||
SetVitalsIdentity: id => _ui.Vitals?.SetLocalPlayerGuid(id),
|
SetVitalsIdentity: id => _ui.Vitals?.SetLocalPlayerGuid(id),
|
||||||
|
|
@ -152,37 +160,26 @@ internal sealed class LiveSessionRuntimeFactory
|
||||||
connectOptions);
|
connectOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
private LiveSessionResetBindings CreateResetBindings() => new()
|
private LiveSessionResetBindings CreateResetBindings(
|
||||||
|
IRuntimeGenerationResetHost resetHost) => new()
|
||||||
{
|
{
|
||||||
MouseCapture = _interaction.GameplayInput.ResetSession,
|
MouseCapture = _interaction.GameplayInput.ResetSession,
|
||||||
PlayerPresentation = ResetPlayerPresentation,
|
PlayerPresentation = ResetPlayerPresentation,
|
||||||
TeleportTransit = _world.Teleport.ResetSession,
|
TeleportPresentation =
|
||||||
|
_world.Teleport.ResetGenerationPresentation,
|
||||||
SessionDialogs = () => _ui.RetailUi?.ResetSessionTransientUi(),
|
SessionDialogs = () => _ui.RetailUi?.ResetSessionTransientUi(),
|
||||||
ChatCommandTargets = _domain.Communication.ResetCommandTargets,
|
|
||||||
SettingsCharacterContext =
|
SettingsCharacterContext =
|
||||||
_interaction.Settings.RestoreDefaultCharacterContext,
|
_interaction.Settings.RestoreDefaultCharacterContext,
|
||||||
EquippedChildren = _world.EquippedChildren.Clear,
|
EquippedChildren = _world.EquippedChildren.Clear,
|
||||||
ExternalContainer = _domain.Inventory.ResetExternalContainer,
|
InteractionPresentation =
|
||||||
InteractionAndSelection = _interaction.SelectionInteractions.ResetSession,
|
_interaction.SelectionInteractions.ResetGenerationPresentation,
|
||||||
InventoryTransactions = _domain.Inventory.ResetTransactions,
|
|
||||||
SelectionPresentation = _world.SelectionScene.Reset,
|
SelectionPresentation = _world.SelectionScene.Reset,
|
||||||
ObjectTable = _domain.EntityObjects.ClearObjects,
|
|
||||||
Spellbook = _domain.Character.ResetSpellbook,
|
|
||||||
MagicRuntime = () => _ui.Magic?.Reset(),
|
|
||||||
CombatAttack = _interaction.CombatAttack.ResetSession,
|
|
||||||
CombatState = _domain.Actions.Combat.Clear,
|
|
||||||
ItemMana = _domain.Inventory.ResetItemMana,
|
|
||||||
LocalPlayer = _domain.Character.ResetLocalPlayer,
|
|
||||||
Friends = _domain.Communication.ResetFriends,
|
|
||||||
Squelch = _domain.Communication.ResetSquelch,
|
|
||||||
TurbineChat = _domain.Communication.ResetNegotiatedChannels,
|
|
||||||
ParticleVisibility = _world.ParticleVisibility.Reset,
|
ParticleVisibility = _world.ParticleVisibility.Reset,
|
||||||
InboundEventFifo = _world.InboundEvents.Clear,
|
InboundEventFifo = _world.InboundEvents.Clear,
|
||||||
LiveLiveness = _world.Liveness.Clear,
|
LiveLiveness = _world.Liveness.Clear,
|
||||||
LiveRuntime = ResetLiveRuntime,
|
RuntimeGeneration = generation =>
|
||||||
RenderSceneProjection =
|
_domain.Runtime.ResetGeneration(generation, resetHost),
|
||||||
() => _world.RenderSceneShadow?.DrainUpdateBoundary(),
|
SessionIdentityPresentation = ResetIdentityPresentation,
|
||||||
SessionIdentity = ResetIdentity,
|
|
||||||
RemoteTeleport = _world.RemoteTeleport.Clear,
|
RemoteTeleport = _world.RemoteTeleport.Clear,
|
||||||
NetworkEffects = _world.EntityEffects.ClearNetworkState,
|
NetworkEffects = _world.EntityEffects.ClearNetworkState,
|
||||||
AnimationHookFrames = _world.AnimationHookFrames.Clear,
|
AnimationHookFrames = _world.AnimationHookFrames.Clear,
|
||||||
|
|
@ -196,28 +193,28 @@ internal sealed class LiveSessionRuntimeFactory
|
||||||
_world.SpawnClaims.Reset();
|
_world.SpawnClaims.Reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ResetLiveRuntime() =>
|
private void ResetIdentityPresentation(
|
||||||
LiveSessionEntityRuntimeReset.ClearAndRequireConvergence(
|
RuntimeGenerationToken retiringGeneration)
|
||||||
_world.LiveEntities);
|
|
||||||
|
|
||||||
private void ResetIdentity()
|
|
||||||
{
|
{
|
||||||
LiveSessionEntityRuntimeReset.RequireConvergence(_world.LiveEntities);
|
RuntimeGenerationResetSnapshot reset =
|
||||||
|
_domain.Runtime.GenerationReset.CaptureSnapshot();
|
||||||
|
if (reset.IsActive
|
||||||
|
|| reset.LastCompletedGeneration != retiringGeneration)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Runtime generation {retiringGeneration.Value} has not "
|
||||||
|
+ "converged before App identity projection teardown.");
|
||||||
|
}
|
||||||
|
|
||||||
// PlayerModule::Clear @ 0x005D48A0 clears shortcuts, desired
|
// PlayerModule::Clear @ 0x005D48A0 clears shortcuts, desired
|
||||||
// components, and character option objects. CPlayerSystem::Begin
|
// components, and character option objects. CPlayerSystem::Begin
|
||||||
// @ 0x0055D410 resets player identity and session fields. The option
|
// @ 0x0055D410 resets player identity and session fields. The option
|
||||||
// default comes from PlayerModule::PlayerModule @ 0x005D51F0.
|
// default comes from PlayerModule::PlayerModule @ 0x005D51F0.
|
||||||
_player.Identity.ServerGuid = 0u;
|
|
||||||
_ui.Vitals?.SetLocalPlayerGuid(0u);
|
_ui.Vitals?.SetLocalPlayerGuid(0u);
|
||||||
_domain.Communication.ResetChatIdentity();
|
|
||||||
EntityVanishProbe.PlayerGuid = 0u;
|
EntityVanishProbe.PlayerGuid = 0u;
|
||||||
_interaction.Settings.ResetActiveCharacterKey();
|
_interaction.Settings.ResetActiveCharacterKey();
|
||||||
_domain.Character.Options.ResetSession();
|
|
||||||
_domain.Character.MovementSkills.ResetSession();
|
|
||||||
_world.NetworkUpdates.ResetSessionState();
|
_world.NetworkUpdates.ResetSessionState();
|
||||||
_world.Hydration.ResetSessionState();
|
_world.Hydration.ResetSessionState();
|
||||||
_domain.Inventory.ResetPlayerSnapshots();
|
|
||||||
_ui.Paperdoll?.ResetSession();
|
_ui.Paperdoll?.ResetSession();
|
||||||
|
|
||||||
// X/Y are ignored until the next logical player CreateObject claims a
|
// X/Y are ignored until the next logical player CreateObject claims a
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ internal interface ILocalPlayerTeleportNetworkSink
|
||||||
bool teleportTimestampAdvanced);
|
bool teleportTimestampAdvanced);
|
||||||
|
|
||||||
void ResetSession();
|
void ResetSession();
|
||||||
|
|
||||||
|
void ResetGenerationPresentation();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -62,6 +64,9 @@ internal sealed class DeferredLocalPlayerTeleportNetworkSink
|
||||||
|
|
||||||
public void ResetSession() => Required().ResetSession();
|
public void ResetSession() => Required().ResetSession();
|
||||||
|
|
||||||
|
public void ResetGenerationPresentation() =>
|
||||||
|
Required().ResetGenerationPresentation();
|
||||||
|
|
||||||
private ILocalPlayerTeleportNetworkSink Required() =>
|
private ILocalPlayerTeleportNetworkSink Required() =>
|
||||||
_inner ?? throw new InvalidOperationException(
|
_inner ?? throw new InvalidOperationException(
|
||||||
"The local teleport sink was used before composition completed.");
|
"The local teleport sink was used before composition completed.");
|
||||||
|
|
@ -567,7 +572,17 @@ internal sealed class LocalPlayerTeleportController
|
||||||
public void ResetSession()
|
public void ResetSession()
|
||||||
{
|
{
|
||||||
ThrowIfDisposed();
|
ThrowIfDisposed();
|
||||||
ResetTransit(clearSession: true);
|
ResetTransit(
|
||||||
|
clearSession: true,
|
||||||
|
resetCanonicalTransit: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ResetGenerationPresentation()
|
||||||
|
{
|
||||||
|
ThrowIfDisposed();
|
||||||
|
ResetTransit(
|
||||||
|
clearSession: true,
|
||||||
|
resetCanonicalTransit: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Matrix4x4 ApplyViewPlane(Matrix4x4 projection) =>
|
public Matrix4x4 ApplyViewPlane(Matrix4x4 projection) =>
|
||||||
|
|
@ -718,7 +733,9 @@ internal sealed class LocalPlayerTeleportController
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private long ResetTransit(bool clearSession)
|
private long ResetTransit(
|
||||||
|
bool clearSession,
|
||||||
|
bool resetCanonicalTransit = false)
|
||||||
{
|
{
|
||||||
long generation = checked(++_lifetimeGeneration);
|
long generation = checked(++_lifetimeGeneration);
|
||||||
|
|
||||||
|
|
@ -733,7 +750,12 @@ internal sealed class LocalPlayerTeleportController
|
||||||
return generation;
|
return generation;
|
||||||
|
|
||||||
if (clearSession)
|
if (clearSession)
|
||||||
|
{
|
||||||
|
if (resetCanonicalTransit)
|
||||||
_worldReveal.ResetSession();
|
_worldReveal.ResetSession();
|
||||||
|
else
|
||||||
|
_worldReveal.ResetHostSession();
|
||||||
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_transit.EndTeleport();
|
_transit.EndTeleport();
|
||||||
|
|
|
||||||
|
|
@ -286,12 +286,22 @@ internal sealed class WorldRevealCoordinator
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ResetSession()
|
public void ResetSession()
|
||||||
|
{
|
||||||
|
ResetHostSession();
|
||||||
|
_transit.ResetSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retires only App streaming/presentation acknowledgements. The shared
|
||||||
|
/// Runtime generation-reset transaction clears canonical transit state
|
||||||
|
/// after this host boundary has converged.
|
||||||
|
/// </summary>
|
||||||
|
internal void ResetHostSession()
|
||||||
{
|
{
|
||||||
long generation = _transit.Snapshot.Generation;
|
long generation = _transit.Snapshot.Generation;
|
||||||
if (generation != 0)
|
if (generation != 0)
|
||||||
_transit.Cancel(generation);
|
_transit.Cancel(generation);
|
||||||
RetryPendingHostWork();
|
RetryPendingHostWork();
|
||||||
_transit.ResetSession();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -1182,11 +1182,24 @@ public sealed class ItemInteractionController : IDisposable
|
||||||
/// same numeric GUID can identify a different object.
|
/// same numeric GUID can identify a different object.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void ResetSession()
|
public void ResetSession()
|
||||||
|
=> ResetSessionCore(resetRuntime: true);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clears only retained UI transaction presentation. Runtime's canonical
|
||||||
|
/// transaction, interaction, and busy owners are reset once by the shared
|
||||||
|
/// generation-reset transaction.
|
||||||
|
/// </summary>
|
||||||
|
internal void ResetGenerationPresentation()
|
||||||
|
=> ResetSessionCore(resetRuntime: false);
|
||||||
|
|
||||||
|
private void ResetSessionCore(bool resetRuntime)
|
||||||
{
|
{
|
||||||
PendingBackpackPlacement? pendingPlacement = _pendingBackpackPlacement;
|
PendingBackpackPlacement? pendingPlacement = _pendingBackpackPlacement;
|
||||||
_pendingBackpackPlacement = null;
|
_pendingBackpackPlacement = null;
|
||||||
// Runtime owns the transaction reset. This controller preserves the
|
if (resetRuntime)
|
||||||
// pre-J4 single UI notification emitted by InteractionState below.
|
{
|
||||||
|
// Runtime owns the transaction reset. This controller preserves
|
||||||
|
// the pre-J4 single UI notification emitted by InteractionState.
|
||||||
_transactions.StateChanged -= OnTransactionStateChanged;
|
_transactions.StateChanged -= OnTransactionStateChanged;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
@ -1197,12 +1210,16 @@ public sealed class ItemInteractionController : IDisposable
|
||||||
if (!_disposed)
|
if (!_disposed)
|
||||||
_transactions.StateChanged += OnTransactionStateChanged;
|
_transactions.StateChanged += OnTransactionStateChanged;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
_consumedPrimaryClickTarget = 0u;
|
_consumedPrimaryClickTarget = 0u;
|
||||||
_consumedPrimaryClickMs = long.MinValue / 2;
|
_consumedPrimaryClickMs = long.MinValue / 2;
|
||||||
|
|
||||||
List<Exception> failures = [];
|
List<Exception> failures = [];
|
||||||
|
if (resetRuntime)
|
||||||
|
{
|
||||||
try { _interactionState.ResetSession(); }
|
try { _interactionState.ResetSession(); }
|
||||||
catch (Exception error) { failures.Add(error); }
|
catch (Exception error) { failures.Add(error); }
|
||||||
|
}
|
||||||
|
|
||||||
if (pendingPlacement is { } pending)
|
if (pendingPlacement is { } pending)
|
||||||
DispatchAll(PendingBackpackPlacementCancelled, pending, failures);
|
DispatchAll(PendingBackpackPlacementCancelled, pending, failures);
|
||||||
|
|
|
||||||
|
|
@ -2230,6 +2230,76 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
|
||||||
IsCurrentRecord(expectedRecord)
|
IsCurrentRecord(expectedRecord)
|
||||||
&& expectedRecord.CreateIntegrationVersion == expectedCreateIntegrationVersion;
|
&& expectedRecord.CreateIntegrationVersion == expectedCreateIntegrationVersion;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retires only the graphical sidecar for an exact Runtime-owned
|
||||||
|
/// incarnation. The Runtime generation-reset transaction retains and
|
||||||
|
/// completes the canonical tombstone after this acknowledgement returns.
|
||||||
|
/// </summary>
|
||||||
|
internal void RetireGenerationProjection(
|
||||||
|
RuntimeEntityRecord canonical)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(canonical);
|
||||||
|
if (_isClearing || _isRegisteringResources)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
_isClearing
|
||||||
|
? "Live entity projection teardown is already in progress."
|
||||||
|
: "Live entity projection teardown cannot begin inside atomic resource registration.");
|
||||||
|
}
|
||||||
|
|
||||||
|
LiveEntityRecord? record = null;
|
||||||
|
if (_projections.TryGet(canonical, out LiveEntityRecord active))
|
||||||
|
{
|
||||||
|
if (!_projections.RemoveActive(active))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Exact App projection for 0x{canonical.ServerGuid:X8}/{canonical.Incarnation} could not be retired during generation reset.");
|
||||||
|
}
|
||||||
|
_projections.RetainTeardown(active);
|
||||||
|
record = active;
|
||||||
|
}
|
||||||
|
else if (_projections.TryGetTeardown(
|
||||||
|
canonical,
|
||||||
|
out LiveEntityRecord retained))
|
||||||
|
{
|
||||||
|
record = retained;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_isClearing = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
TearDownRecord(record, completeCanonical: false);
|
||||||
|
_projections.ReleaseTeardown(record);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_isClearing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Completes the App-only projection boundary after Runtime has retired
|
||||||
|
/// every exact canonical incarnation. No canonical owner is mutated here.
|
||||||
|
/// </summary>
|
||||||
|
internal void CompleteGenerationProjectionRetirement()
|
||||||
|
{
|
||||||
|
if (_projections.ActiveCount != 0
|
||||||
|
|| _projections.TeardownCount != 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Live entity projection storage has not converged at the generation-reset boundary.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_spatialAnimations.Clear();
|
||||||
|
_physics.ClearSpatialWorksets();
|
||||||
|
_projections.ClearConverged();
|
||||||
|
_spatial.ClearLiveEntityLifetimeState();
|
||||||
|
_sessionClearPendingFinalization = false;
|
||||||
|
}
|
||||||
|
|
||||||
public void Clear()
|
public void Clear()
|
||||||
{
|
{
|
||||||
if (_isClearing || _isRegisteringResources)
|
if (_isClearing || _isRegisteringResources)
|
||||||
|
|
@ -2670,7 +2740,9 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
|
||||||
_sessionClearPendingFinalization = false;
|
_sessionClearPendingFinalization = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void TearDownRecord(LiveEntityRecord record)
|
private void TearDownRecord(
|
||||||
|
LiveEntityRecord record,
|
||||||
|
bool completeCanonical = true)
|
||||||
{
|
{
|
||||||
if (record.TeardownInProgress)
|
if (record.TeardownInProgress)
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
|
|
@ -2730,8 +2802,11 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
|
||||||
|
|
||||||
if (record.WorldEntity is not null)
|
if (record.WorldEntity is not null)
|
||||||
_ = RequireProjectionKey(record);
|
_ = RequireProjectionKey(record);
|
||||||
|
if (completeCanonical)
|
||||||
|
{
|
||||||
_entityObjects.CompleteProjectionRetirement(
|
_entityObjects.CompleteProjectionRetirement(
|
||||||
record.Canonical);
|
record.Canonical);
|
||||||
|
}
|
||||||
|
|
||||||
record.AnimationRuntime = null;
|
record.AnimationRuntime = null;
|
||||||
record.EffectProfile = null;
|
record.EffectProfile = null;
|
||||||
|
|
|
||||||
|
|
@ -968,15 +968,55 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
|
||||||
|
|
||||||
_sessionClearInProgress = true;
|
_sessionClearInProgress = true;
|
||||||
Entities.BeginSessionClear();
|
Entities.BeginSessionClear();
|
||||||
RuntimeEntityRecord[] retired = Entities.ActiveRecords.ToArray();
|
RuntimeEntityRecord[] active = Entities.ActiveRecords.ToArray();
|
||||||
foreach (RuntimeEntityRecord canonical in retired)
|
foreach (RuntimeEntityRecord canonical in active)
|
||||||
{
|
{
|
||||||
if (!Entities.RemoveActive(canonical))
|
if (!Entities.RemoveActive(canonical))
|
||||||
continue;
|
continue;
|
||||||
Entities.RetainTeardown(canonical);
|
Entities.RetainTeardown(canonical);
|
||||||
PublishEntity(RuntimeEntityChange.Deleted, canonical);
|
PublishEntity(RuntimeEntityChange.Deleted, canonical);
|
||||||
}
|
}
|
||||||
return retired;
|
return active;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<RuntimeEntityRecord>
|
||||||
|
CaptureSessionClearRetirements()
|
||||||
|
{
|
||||||
|
EnsureNotDisposed();
|
||||||
|
if (!_sessionClearInProgress)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Session-clear retirements are only available while the "
|
||||||
|
+ "canonical clear transaction is active.");
|
||||||
|
}
|
||||||
|
return Entities.TeardownRecords.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Completes one exact incarnation retained by
|
||||||
|
/// <see cref="BeginSessionClear"/> after the borrowed host has retired its
|
||||||
|
/// presentation projection. Failure keeps the tombstone and local
|
||||||
|
/// identity intact so the same transaction cursor can retry this exact
|
||||||
|
/// record without reconstructing the retirement set.
|
||||||
|
/// </summary>
|
||||||
|
public void CompleteSessionEntityRetirement(
|
||||||
|
RuntimeEntityRecord canonical)
|
||||||
|
{
|
||||||
|
EnsureNotDisposed();
|
||||||
|
ArgumentNullException.ThrowIfNull(canonical);
|
||||||
|
if (!_sessionClearInProgress
|
||||||
|
|| !Entities.TryGetTeardown(
|
||||||
|
canonical.ServerGuid,
|
||||||
|
canonical.Incarnation,
|
||||||
|
out RuntimeEntityRecord retained)
|
||||||
|
|| !ReferenceEquals(retained, canonical))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Live entity 0x{canonical.ServerGuid:X8}/{canonical.Incarnation} is not retained by the active session-clear transaction.");
|
||||||
|
}
|
||||||
|
|
||||||
|
CompleteProjectionRetirement(canonical);
|
||||||
|
Entities.ReleaseTeardown(canonical);
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool CompleteSessionClearIfConverged()
|
public bool CompleteSessionClearIfConverged()
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ public readonly record struct GameRuntimeOwnershipSnapshot(
|
||||||
RuntimeSimulationOwnershipSnapshot Simulation,
|
RuntimeSimulationOwnershipSnapshot Simulation,
|
||||||
RuntimeWorldEnvironmentOwnershipSnapshot Environment,
|
RuntimeWorldEnvironmentOwnershipSnapshot Environment,
|
||||||
RuntimeWorldTransitOwnershipSnapshot Transit,
|
RuntimeWorldTransitOwnershipSnapshot Transit,
|
||||||
|
RuntimeGenerationResetSnapshot GenerationReset,
|
||||||
GameRuntimeEventOwnershipSnapshot Events)
|
GameRuntimeEventOwnershipSnapshot Events)
|
||||||
{
|
{
|
||||||
public bool IsConverged =>
|
public bool IsConverged =>
|
||||||
|
|
@ -70,6 +71,7 @@ public readonly record struct GameRuntimeOwnershipSnapshot(
|
||||||
&& PlayerIdentity.IsConverged
|
&& PlayerIdentity.IsConverged
|
||||||
&& Simulation.IsConverged
|
&& Simulation.IsConverged
|
||||||
&& Transit.IsSessionIdle
|
&& Transit.IsSessionIdle
|
||||||
|
&& GenerationReset.IsConverged
|
||||||
&& Events.IsConverged;
|
&& Events.IsConverged;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -241,8 +243,19 @@ public sealed class GameRuntime
|
||||||
context,
|
context,
|
||||||
faultInjection);
|
faultInjection);
|
||||||
|
|
||||||
|
var generationReset = new RuntimeGenerationReset(
|
||||||
|
transit,
|
||||||
|
context.Communication,
|
||||||
|
context.Inventory,
|
||||||
|
context.Actions,
|
||||||
|
context.Movement,
|
||||||
|
context.EntityObjects,
|
||||||
|
context.Character,
|
||||||
|
context.PlayerIdentity);
|
||||||
|
|
||||||
context.EntityObjects.BindEventContext(
|
context.EntityObjects.BindEventContext(
|
||||||
() => context.Session.Generation,
|
() => generationReset.ActiveRetiringGeneration
|
||||||
|
?? context.Session.Generation,
|
||||||
() => clock.FrameNumber);
|
() => clock.FrameNumber);
|
||||||
context.Events = new GameRuntimeEventHub(
|
context.Events = new GameRuntimeEventHub(
|
||||||
context.EntityObjects,
|
context.EntityObjects,
|
||||||
|
|
@ -264,6 +277,7 @@ public sealed class GameRuntime
|
||||||
ActionOwner = context.Actions;
|
ActionOwner = context.Actions;
|
||||||
EnvironmentOwner = environment;
|
EnvironmentOwner = environment;
|
||||||
TransitOwner = transit;
|
TransitOwner = transit;
|
||||||
|
GenerationReset = generationReset;
|
||||||
_events = context.Events;
|
_events = context.Events;
|
||||||
construction.Complete();
|
construction.Complete();
|
||||||
}
|
}
|
||||||
|
|
@ -285,6 +299,7 @@ public sealed class GameRuntime
|
||||||
public RuntimeLocalPlayerMovementState MovementOwner { get; }
|
public RuntimeLocalPlayerMovementState MovementOwner { get; }
|
||||||
public RuntimeWorldEnvironmentState EnvironmentOwner { get; }
|
public RuntimeWorldEnvironmentState EnvironmentOwner { get; }
|
||||||
public RuntimeWorldTransitState TransitOwner { get; }
|
public RuntimeWorldTransitState TransitOwner { get; }
|
||||||
|
public RuntimeGenerationReset GenerationReset { get; }
|
||||||
|
|
||||||
public RuntimeGenerationToken Generation => Session.Generation;
|
public RuntimeGenerationToken Generation => Session.Generation;
|
||||||
|
|
||||||
|
|
@ -347,6 +362,11 @@ public sealed class GameRuntime
|
||||||
Portal.Snapshot,
|
Portal.Snapshot,
|
||||||
Portal.Ownership);
|
Portal.Ownership);
|
||||||
|
|
||||||
|
public void ResetGeneration(
|
||||||
|
RuntimeGenerationToken retiringGeneration,
|
||||||
|
IRuntimeGenerationResetHost host) =>
|
||||||
|
GenerationReset.Reset(retiringGeneration, host);
|
||||||
|
|
||||||
public IDisposable Subscribe(IRuntimeEventObserver observer)
|
public IDisposable Subscribe(IRuntimeEventObserver observer)
|
||||||
{
|
{
|
||||||
lock (_lifetimeGate)
|
lock (_lifetimeGate)
|
||||||
|
|
@ -393,6 +413,7 @@ public sealed class GameRuntime
|
||||||
MovementOwner),
|
MovementOwner),
|
||||||
EnvironmentOwner.CaptureOwnership(),
|
EnvironmentOwner.CaptureOwnership(),
|
||||||
TransitOwner.CaptureOwnership(),
|
TransitOwner.CaptureOwnership(),
|
||||||
|
GenerationReset.CaptureSnapshot(),
|
||||||
_events.CaptureOwnership());
|
_events.CaptureOwnership());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -534,6 +555,7 @@ public sealed class GameRuntime
|
||||||
StopSession();
|
StopSession();
|
||||||
return Session.CaptureOwnership().IsConverged;
|
return Session.CaptureOwnership().IsConverged;
|
||||||
case 3:
|
case 3:
|
||||||
|
GenerationReset.DrainPending();
|
||||||
TransitOwner.ResetSession();
|
TransitOwner.ResetSession();
|
||||||
return TransitOwner.CaptureOwnership().IsSessionIdle;
|
return TransitOwner.CaptureOwnership().IsSessionIdle;
|
||||||
case 4:
|
case 4:
|
||||||
|
|
|
||||||
|
|
@ -134,6 +134,26 @@ public sealed class RuntimeLocalPlayerMovementState
|
||||||
Interlocked.Increment(ref _revision);
|
Interlocked.Increment(ref _revision);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retires every character-generation movement owner while keeping the
|
||||||
|
/// process-scoped Runtime root reusable for the next session.
|
||||||
|
/// Graphical hosts release their camera/physics projections first; direct
|
||||||
|
/// hosts have no parallel projection to coordinate.
|
||||||
|
/// </summary>
|
||||||
|
public void ResetSession()
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
|
bool changed =
|
||||||
|
_autoRunActive
|
||||||
|
|| _controller is not null
|
||||||
|
|| _preparingMotionOwner is not null;
|
||||||
|
_autoRunActive = false;
|
||||||
|
_controller = null;
|
||||||
|
_preparingMotionOwner = null;
|
||||||
|
if (changed)
|
||||||
|
Interlocked.Increment(ref _revision);
|
||||||
|
}
|
||||||
|
|
||||||
public RuntimeLocalMovementOwnershipSnapshot CaptureOwnership() =>
|
public RuntimeLocalMovementOwnershipSnapshot CaptureOwnership() =>
|
||||||
new(
|
new(
|
||||||
_disposed,
|
_disposed,
|
||||||
|
|
|
||||||
387
src/AcDream.Runtime/RuntimeGenerationReset.cs
Normal file
387
src/AcDream.Runtime/RuntimeGenerationReset.cs
Normal file
|
|
@ -0,0 +1,387 @@
|
||||||
|
using AcDream.Runtime.Entities;
|
||||||
|
using AcDream.Runtime.Gameplay;
|
||||||
|
using AcDream.Runtime.World;
|
||||||
|
|
||||||
|
namespace AcDream.Runtime;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The only host-specific edge in a character-generation reset. The host
|
||||||
|
/// retires presentation borrowed from an exact Runtime incarnation; Runtime
|
||||||
|
/// retains the retirement set, cursor, canonical cleanup, and generation.
|
||||||
|
/// </summary>
|
||||||
|
public interface IRuntimeGenerationResetHost
|
||||||
|
{
|
||||||
|
void RetireEntityProjection(RuntimeEntityRecord entity);
|
||||||
|
|
||||||
|
void DrainEntityProjectionBoundary();
|
||||||
|
|
||||||
|
void CompleteEntityProjectionRetirement();
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum RuntimeGenerationResetStage
|
||||||
|
{
|
||||||
|
None = 0,
|
||||||
|
Transit = 1,
|
||||||
|
CommandTargets = 2,
|
||||||
|
ExternalContainer = 3,
|
||||||
|
Actions = 4,
|
||||||
|
Movement = 5,
|
||||||
|
ObjectTable = 6,
|
||||||
|
Character = 7,
|
||||||
|
ItemMana = 8,
|
||||||
|
Friends = 9,
|
||||||
|
Squelch = 10,
|
||||||
|
NegotiatedChannels = 11,
|
||||||
|
BeginEntityRetirement = 12,
|
||||||
|
RetireEntities = 13,
|
||||||
|
DrainHostProjection = 14,
|
||||||
|
CompleteCanonicalEntities = 15,
|
||||||
|
CompleteHostProjection = 16,
|
||||||
|
ChatIdentity = 17,
|
||||||
|
PlayerSnapshots = 18,
|
||||||
|
PlayerIdentity = 19,
|
||||||
|
Complete = 20,
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly record struct RuntimeGenerationResetSnapshot(
|
||||||
|
bool IsActive,
|
||||||
|
bool IsExecuting,
|
||||||
|
RuntimeGenerationToken RetiringGeneration,
|
||||||
|
RuntimeGenerationToken LastCompletedGeneration,
|
||||||
|
RuntimeGenerationResetStage Stage,
|
||||||
|
int RetirementCount,
|
||||||
|
int RetirementCursor,
|
||||||
|
bool CurrentProjectionAcknowledged,
|
||||||
|
long TransactionId)
|
||||||
|
{
|
||||||
|
public bool IsConverged =>
|
||||||
|
!IsActive
|
||||||
|
&& !IsExecuting;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class RuntimeGenerationResetStageException(
|
||||||
|
RuntimeGenerationToken retiringGeneration,
|
||||||
|
RuntimeGenerationResetStage stage,
|
||||||
|
Exception innerException) : Exception(
|
||||||
|
$"Runtime generation {retiringGeneration.Value} reset stage "
|
||||||
|
+ $"'{stage}' did not converge.",
|
||||||
|
innerException)
|
||||||
|
{
|
||||||
|
public RuntimeGenerationToken RetiringGeneration { get; } =
|
||||||
|
retiringGeneration;
|
||||||
|
|
||||||
|
public RuntimeGenerationResetStage Stage { get; } = stage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One persisted, retryable reset transaction for every canonical owner in a
|
||||||
|
/// reusable <see cref="GameRuntime"/>. A failed call retains the exact suffix:
|
||||||
|
/// completed stages and exact-incarnation retirements never replay.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RuntimeGenerationReset
|
||||||
|
{
|
||||||
|
private readonly RuntimeWorldTransitState _transit;
|
||||||
|
private readonly RuntimeCommunicationState _communication;
|
||||||
|
private readonly RuntimeInventoryState _inventory;
|
||||||
|
private readonly RuntimeActionState _actions;
|
||||||
|
private readonly RuntimeLocalPlayerMovementState _movement;
|
||||||
|
private readonly RuntimeEntityObjectLifetime _entityObjects;
|
||||||
|
private readonly RuntimeCharacterState _character;
|
||||||
|
private readonly RuntimeLocalPlayerIdentityState _identity;
|
||||||
|
private ResetState? _state;
|
||||||
|
private RuntimeGenerationToken _lastCompletedGeneration;
|
||||||
|
private bool _hasCompletedGeneration;
|
||||||
|
private bool _executing;
|
||||||
|
private long _nextTransactionId;
|
||||||
|
|
||||||
|
internal RuntimeGenerationReset(
|
||||||
|
RuntimeWorldTransitState transit,
|
||||||
|
RuntimeCommunicationState communication,
|
||||||
|
RuntimeInventoryState inventory,
|
||||||
|
RuntimeActionState actions,
|
||||||
|
RuntimeLocalPlayerMovementState movement,
|
||||||
|
RuntimeEntityObjectLifetime entityObjects,
|
||||||
|
RuntimeCharacterState character,
|
||||||
|
RuntimeLocalPlayerIdentityState identity)
|
||||||
|
{
|
||||||
|
_transit = transit ?? throw new ArgumentNullException(nameof(transit));
|
||||||
|
_communication = communication
|
||||||
|
?? throw new ArgumentNullException(nameof(communication));
|
||||||
|
_inventory = inventory
|
||||||
|
?? throw new ArgumentNullException(nameof(inventory));
|
||||||
|
_actions = actions ?? throw new ArgumentNullException(nameof(actions));
|
||||||
|
_movement = movement
|
||||||
|
?? throw new ArgumentNullException(nameof(movement));
|
||||||
|
_entityObjects = entityObjects
|
||||||
|
?? throw new ArgumentNullException(nameof(entityObjects));
|
||||||
|
_character = character
|
||||||
|
?? throw new ArgumentNullException(nameof(character));
|
||||||
|
_identity = identity
|
||||||
|
?? throw new ArgumentNullException(nameof(identity));
|
||||||
|
}
|
||||||
|
|
||||||
|
public RuntimeGenerationToken? ActiveRetiringGeneration =>
|
||||||
|
_state?.Generation;
|
||||||
|
|
||||||
|
public RuntimeGenerationResetSnapshot CaptureSnapshot()
|
||||||
|
{
|
||||||
|
ResetState? state = _state;
|
||||||
|
return state is null
|
||||||
|
? new RuntimeGenerationResetSnapshot(
|
||||||
|
false,
|
||||||
|
_executing,
|
||||||
|
default,
|
||||||
|
_lastCompletedGeneration,
|
||||||
|
_hasCompletedGeneration
|
||||||
|
? RuntimeGenerationResetStage.Complete
|
||||||
|
: RuntimeGenerationResetStage.None,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
false,
|
||||||
|
0)
|
||||||
|
: new RuntimeGenerationResetSnapshot(
|
||||||
|
true,
|
||||||
|
_executing,
|
||||||
|
state.Generation,
|
||||||
|
_lastCompletedGeneration,
|
||||||
|
state.Stage,
|
||||||
|
state.Retirements?.Length ?? 0,
|
||||||
|
state.RetirementCursor,
|
||||||
|
state.CurrentProjectionAcknowledged,
|
||||||
|
state.TransactionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Reset(
|
||||||
|
RuntimeGenerationToken retiringGeneration,
|
||||||
|
IRuntimeGenerationResetHost host)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(host);
|
||||||
|
if (_executing)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Runtime generation reset cannot run concurrently or reentrantly.");
|
||||||
|
}
|
||||||
|
|
||||||
|
ResetState state = AcquireState(retiringGeneration, host);
|
||||||
|
if (state.Stage is RuntimeGenerationResetStage.Complete)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_executing = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Drain(state);
|
||||||
|
}
|
||||||
|
catch (Exception error)
|
||||||
|
{
|
||||||
|
throw new RuntimeGenerationResetStageException(
|
||||||
|
state.Generation,
|
||||||
|
state.Stage,
|
||||||
|
error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_executing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void DrainPending()
|
||||||
|
{
|
||||||
|
ResetState? state = _state;
|
||||||
|
if (state is null)
|
||||||
|
return;
|
||||||
|
Reset(state.Generation, state.Host);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResetState AcquireState(
|
||||||
|
RuntimeGenerationToken generation,
|
||||||
|
IRuntimeGenerationResetHost host)
|
||||||
|
{
|
||||||
|
if (_state is { } pending)
|
||||||
|
{
|
||||||
|
if (pending.Generation != generation)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Runtime generation {pending.Generation.Value} reset "
|
||||||
|
+ $"must converge before generation {generation.Value} can reset.");
|
||||||
|
}
|
||||||
|
if (!ReferenceEquals(pending.Host, host))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"An in-progress Runtime generation reset cannot replace "
|
||||||
|
+ "its borrowed projection host.");
|
||||||
|
}
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_hasCompletedGeneration)
|
||||||
|
{
|
||||||
|
if (generation == _lastCompletedGeneration)
|
||||||
|
return ResetState.Completed(generation, host);
|
||||||
|
if (generation.Value < _lastCompletedGeneration.Value)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Runtime generation {generation.Value} reset is stale; "
|
||||||
|
+ $"generation {_lastCompletedGeneration.Value} already converged.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var created = new ResetState(
|
||||||
|
generation,
|
||||||
|
host,
|
||||||
|
checked(++_nextTransactionId));
|
||||||
|
_state = created;
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Drain(ResetState state)
|
||||||
|
{
|
||||||
|
while (state.Stage is not RuntimeGenerationResetStage.Complete)
|
||||||
|
{
|
||||||
|
switch (state.Stage)
|
||||||
|
{
|
||||||
|
case RuntimeGenerationResetStage.Transit:
|
||||||
|
Advance(state, _transit.ResetSession);
|
||||||
|
break;
|
||||||
|
case RuntimeGenerationResetStage.CommandTargets:
|
||||||
|
Advance(state, _communication.ResetCommandTargets);
|
||||||
|
break;
|
||||||
|
case RuntimeGenerationResetStage.ExternalContainer:
|
||||||
|
Advance(state, _inventory.ResetExternalContainer);
|
||||||
|
break;
|
||||||
|
case RuntimeGenerationResetStage.Actions:
|
||||||
|
Advance(state, _actions.ResetSession);
|
||||||
|
break;
|
||||||
|
case RuntimeGenerationResetStage.Movement:
|
||||||
|
Advance(state, _movement.ResetSession);
|
||||||
|
break;
|
||||||
|
case RuntimeGenerationResetStage.ObjectTable:
|
||||||
|
Advance(state, _entityObjects.ClearObjects);
|
||||||
|
break;
|
||||||
|
case RuntimeGenerationResetStage.Character:
|
||||||
|
Advance(state, _character.ResetSession);
|
||||||
|
break;
|
||||||
|
case RuntimeGenerationResetStage.ItemMana:
|
||||||
|
Advance(state, _inventory.ResetItemMana);
|
||||||
|
break;
|
||||||
|
case RuntimeGenerationResetStage.Friends:
|
||||||
|
Advance(state, _communication.ResetFriends);
|
||||||
|
break;
|
||||||
|
case RuntimeGenerationResetStage.Squelch:
|
||||||
|
Advance(state, _communication.ResetSquelch);
|
||||||
|
break;
|
||||||
|
case RuntimeGenerationResetStage.NegotiatedChannels:
|
||||||
|
Advance(
|
||||||
|
state,
|
||||||
|
_communication.ResetNegotiatedChannels);
|
||||||
|
break;
|
||||||
|
case RuntimeGenerationResetStage.BeginEntityRetirement:
|
||||||
|
_ = _entityObjects.BeginSessionClear();
|
||||||
|
state.Retirements = _entityObjects
|
||||||
|
.CaptureSessionClearRetirements()
|
||||||
|
.ToArray();
|
||||||
|
state.Stage = RuntimeGenerationResetStage.RetireEntities;
|
||||||
|
break;
|
||||||
|
case RuntimeGenerationResetStage.RetireEntities:
|
||||||
|
RetireCurrentEntity(state);
|
||||||
|
break;
|
||||||
|
case RuntimeGenerationResetStage.DrainHostProjection:
|
||||||
|
state.Host.DrainEntityProjectionBoundary();
|
||||||
|
state.Stage =
|
||||||
|
RuntimeGenerationResetStage.CompleteCanonicalEntities;
|
||||||
|
break;
|
||||||
|
case RuntimeGenerationResetStage.CompleteCanonicalEntities:
|
||||||
|
if (!_entityObjects.CompleteSessionClearIfConverged())
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Canonical entity/object lifetime still owns an "
|
||||||
|
+ "unacknowledged session retirement.");
|
||||||
|
}
|
||||||
|
state.Stage =
|
||||||
|
RuntimeGenerationResetStage.CompleteHostProjection;
|
||||||
|
break;
|
||||||
|
case RuntimeGenerationResetStage.CompleteHostProjection:
|
||||||
|
state.Host.CompleteEntityProjectionRetirement();
|
||||||
|
state.Stage = RuntimeGenerationResetStage.ChatIdentity;
|
||||||
|
break;
|
||||||
|
case RuntimeGenerationResetStage.ChatIdentity:
|
||||||
|
Advance(state, _communication.ResetChatIdentity);
|
||||||
|
break;
|
||||||
|
case RuntimeGenerationResetStage.PlayerSnapshots:
|
||||||
|
Advance(state, _inventory.ResetPlayerSnapshots);
|
||||||
|
break;
|
||||||
|
case RuntimeGenerationResetStage.PlayerIdentity:
|
||||||
|
_identity.ResetSession();
|
||||||
|
Complete(state);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Unsupported Runtime generation reset stage {state.Stage}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RetireCurrentEntity(ResetState state)
|
||||||
|
{
|
||||||
|
RuntimeEntityRecord[] retirements = state.Retirements ?? [];
|
||||||
|
if (state.RetirementCursor >= retirements.Length)
|
||||||
|
{
|
||||||
|
state.Stage = RuntimeGenerationResetStage.DrainHostProjection;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
RuntimeEntityRecord current = retirements[state.RetirementCursor];
|
||||||
|
if (!state.CurrentProjectionAcknowledged)
|
||||||
|
{
|
||||||
|
state.Host.RetireEntityProjection(current);
|
||||||
|
state.CurrentProjectionAcknowledged = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
_entityObjects.CompleteSessionEntityRetirement(current);
|
||||||
|
state.CurrentProjectionAcknowledged = false;
|
||||||
|
state.RetirementCursor++;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Advance(ResetState state, Action action)
|
||||||
|
{
|
||||||
|
action();
|
||||||
|
state.Stage++;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Complete(ResetState state)
|
||||||
|
{
|
||||||
|
state.Stage = RuntimeGenerationResetStage.Complete;
|
||||||
|
_lastCompletedGeneration = state.Generation;
|
||||||
|
_hasCompletedGeneration = true;
|
||||||
|
_state = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ResetState
|
||||||
|
{
|
||||||
|
public ResetState(
|
||||||
|
RuntimeGenerationToken generation,
|
||||||
|
IRuntimeGenerationResetHost host,
|
||||||
|
long transactionId)
|
||||||
|
{
|
||||||
|
Generation = generation;
|
||||||
|
Host = host;
|
||||||
|
TransactionId = transactionId;
|
||||||
|
Stage = RuntimeGenerationResetStage.Transit;
|
||||||
|
}
|
||||||
|
|
||||||
|
public RuntimeGenerationToken Generation { get; }
|
||||||
|
public IRuntimeGenerationResetHost Host { get; }
|
||||||
|
public long TransactionId { get; }
|
||||||
|
public RuntimeGenerationResetStage Stage { get; set; }
|
||||||
|
public RuntimeEntityRecord[]? Retirements { get; set; }
|
||||||
|
public int RetirementCursor { get; set; }
|
||||||
|
public bool CurrentProjectionAcknowledged { get; set; }
|
||||||
|
|
||||||
|
public static ResetState Completed(
|
||||||
|
RuntimeGenerationToken generation,
|
||||||
|
IRuntimeGenerationResetHost host) =>
|
||||||
|
new(generation, host, 0)
|
||||||
|
{
|
||||||
|
Stage = RuntimeGenerationResetStage.Complete,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -58,7 +58,7 @@ public sealed record LiveSessionStartResult(
|
||||||
public interface ILiveSessionLifecycleHost
|
public interface ILiveSessionLifecycleHost
|
||||||
{
|
{
|
||||||
LiveSessionBinding BindSession(WorldSession session);
|
LiveSessionBinding BindSession(WorldSession session);
|
||||||
void ResetSessionState();
|
void ResetSessionState(RuntimeGenerationToken retiringGeneration);
|
||||||
void ReportConnecting(string host, int port, string user);
|
void ReportConnecting(string host, int port, string user);
|
||||||
void ReportConnected();
|
void ReportConnected();
|
||||||
void ApplySelectedCharacter(LiveSessionCharacterSelection selection);
|
void ApplySelectedCharacter(LiveSessionCharacterSelection selection);
|
||||||
|
|
@ -185,12 +185,14 @@ public sealed class LiveSessionController
|
||||||
{
|
{
|
||||||
private sealed class SessionScope(
|
private sealed class SessionScope(
|
||||||
WorldSession session,
|
WorldSession session,
|
||||||
ILiveSessionLifecycleHost host)
|
ILiveSessionLifecycleHost host,
|
||||||
|
RuntimeGenerationToken generation)
|
||||||
{
|
{
|
||||||
private int _teardownStage;
|
private int _teardownStage;
|
||||||
|
|
||||||
public WorldSession Session { get; } = session;
|
public WorldSession Session { get; } = session;
|
||||||
public ILiveSessionLifecycleHost Host { get; } = host;
|
public ILiveSessionLifecycleHost Host { get; } = host;
|
||||||
|
public RuntimeGenerationToken Generation { get; } = generation;
|
||||||
public LiveSessionBinding? Binding { get; set; }
|
public LiveSessionBinding? Binding { get; set; }
|
||||||
public bool HostAttached { get; set; }
|
public bool HostAttached { get; set; }
|
||||||
public RuntimeTeardownStage CompletedStages { get; private set; }
|
public RuntimeTeardownStage CompletedStages { get; private set; }
|
||||||
|
|
@ -226,7 +228,7 @@ public sealed class LiveSessionController
|
||||||
}
|
}
|
||||||
if (_teardownStage == 3)
|
if (_teardownStage == 3)
|
||||||
{
|
{
|
||||||
Host.ResetSessionState();
|
Host.ResetSessionState(Generation);
|
||||||
CompletedStages |= RuntimeTeardownStage.HostReset;
|
CompletedStages |= RuntimeTeardownStage.HostReset;
|
||||||
_teardownStage = 4;
|
_teardownStage = 4;
|
||||||
}
|
}
|
||||||
|
|
@ -247,11 +249,15 @@ public sealed class LiveSessionController
|
||||||
LiveSessionConnectOptions? Options = null,
|
LiveSessionConnectOptions? Options = null,
|
||||||
ILiveSessionLifecycleHost? Host = null);
|
ILiveSessionLifecycleHost? Host = null);
|
||||||
|
|
||||||
|
private sealed record PendingHostReset(
|
||||||
|
ILiveSessionLifecycleHost Host,
|
||||||
|
RuntimeGenerationToken Generation);
|
||||||
|
|
||||||
private readonly object _gate = new();
|
private readonly object _gate = new();
|
||||||
private readonly ILiveSessionOperations _operations;
|
private readonly ILiveSessionOperations _operations;
|
||||||
private SessionScope? _scope;
|
private SessionScope? _scope;
|
||||||
private SessionScope? _retiredScope;
|
private SessionScope? _retiredScope;
|
||||||
private ILiveSessionLifecycleHost? _pendingInitialResetHost;
|
private PendingHostReset? _pendingInitialReset;
|
||||||
private PendingOperation? _pendingOperation;
|
private PendingOperation? _pendingOperation;
|
||||||
private int _operationDepth;
|
private int _operationDepth;
|
||||||
private bool _inWorld;
|
private bool _inWorld;
|
||||||
|
|
@ -306,7 +312,7 @@ public sealed class LiveSessionController
|
||||||
_inWorld,
|
_inWorld,
|
||||||
_scope is not null,
|
_scope is not null,
|
||||||
_retiredScope is not null,
|
_retiredScope is not null,
|
||||||
_pendingInitialResetHost is not null,
|
_pendingInitialReset is not null,
|
||||||
_pendingOperation is not null,
|
_pendingOperation is not null,
|
||||||
_operationDepth,
|
_operationDepth,
|
||||||
_generation,
|
_generation,
|
||||||
|
|
@ -460,7 +466,9 @@ public sealed class LiveSessionController
|
||||||
ILiveSessionLifecycleHost host)
|
ILiveSessionLifecycleHost host)
|
||||||
{
|
{
|
||||||
ILiveSessionLifecycleHost? oldHost =
|
ILiveSessionLifecycleHost? oldHost =
|
||||||
_scope?.Host ?? _retiredScope?.Host ?? _pendingInitialResetHost;
|
_scope?.Host
|
||||||
|
?? _retiredScope?.Host
|
||||||
|
?? _pendingInitialReset?.Host;
|
||||||
ulong generationBeforeStop = _generation;
|
ulong generationBeforeStop = _generation;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
@ -480,6 +488,7 @@ public sealed class LiveSessionController
|
||||||
ILiveSessionLifecycleHost host,
|
ILiveSessionLifecycleHost host,
|
||||||
bool resetHost)
|
bool resetHost)
|
||||||
{
|
{
|
||||||
|
RuntimeGenerationToken resetGeneration = new(_generation);
|
||||||
ulong generation = ++_generation;
|
ulong generation = ++_generation;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
@ -487,7 +496,12 @@ public sealed class LiveSessionController
|
||||||
if (_generation != generation)
|
if (_generation != generation)
|
||||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||||
if (resetHost)
|
if (resetHost)
|
||||||
ResetHostBeforeStart(host, generation);
|
{
|
||||||
|
ResetHostBeforeStart(
|
||||||
|
host,
|
||||||
|
generation,
|
||||||
|
resetGeneration);
|
||||||
|
}
|
||||||
if (_generation != generation)
|
if (_generation != generation)
|
||||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||||
}
|
}
|
||||||
|
|
@ -509,7 +523,10 @@ public sealed class LiveSessionController
|
||||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||||
Console.WriteLine($"live: connecting to {endpoint} as {options.User}");
|
Console.WriteLine($"live: connecting to {endpoint} as {options.User}");
|
||||||
WorldSession session = _operations.CreateSession(endpoint);
|
WorldSession session = _operations.CreateSession(endpoint);
|
||||||
scope = new SessionScope(session, host);
|
scope = new SessionScope(
|
||||||
|
session,
|
||||||
|
host,
|
||||||
|
new RuntimeGenerationToken(generation));
|
||||||
_scope = scope;
|
_scope = scope;
|
||||||
_inWorld = false;
|
_inWorld = false;
|
||||||
if (!IsCurrent(scope, generation))
|
if (!IsCurrent(scope, generation))
|
||||||
|
|
@ -614,7 +631,7 @@ public sealed class LiveSessionController
|
||||||
}
|
}
|
||||||
DrainRetiredScope();
|
DrainRetiredScope();
|
||||||
DrainPendingInitialReset();
|
DrainPendingInitialReset();
|
||||||
if (_retiredScope is null && _pendingInitialResetHost is null)
|
if (_retiredScope is null && _pendingInitialReset is null)
|
||||||
_lastTeardownStages = RuntimeTeardownStage.Complete;
|
_lastTeardownStages = RuntimeTeardownStage.Complete;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -632,35 +649,38 @@ public sealed class LiveSessionController
|
||||||
|
|
||||||
private void ResetHostBeforeStart(
|
private void ResetHostBeforeStart(
|
||||||
ILiveSessionLifecycleHost host,
|
ILiveSessionLifecycleHost host,
|
||||||
ulong generation)
|
ulong generation,
|
||||||
|
RuntimeGenerationToken resetGeneration)
|
||||||
{
|
{
|
||||||
bool requestedHostAlreadyReset = false;
|
bool requestedHostAlreadyReset = false;
|
||||||
if (_pendingInitialResetHost is { } pending)
|
if (_pendingInitialReset is { } pending)
|
||||||
{
|
{
|
||||||
pending.ResetSessionState();
|
pending.Host.ResetSessionState(pending.Generation);
|
||||||
_pendingInitialResetHost = null;
|
_pendingInitialReset = null;
|
||||||
requestedHostAlreadyReset = ReferenceEquals(pending, host);
|
requestedHostAlreadyReset =
|
||||||
|
ReferenceEquals(pending.Host, host);
|
||||||
}
|
}
|
||||||
if (requestedHostAlreadyReset || _generation != generation)
|
if (requestedHostAlreadyReset || _generation != generation)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
host.ResetSessionState();
|
host.ResetSessionState(resetGeneration);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
_pendingInitialResetHost = host;
|
_pendingInitialReset =
|
||||||
|
new PendingHostReset(host, resetGeneration);
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void DrainPendingInitialReset()
|
private void DrainPendingInitialReset()
|
||||||
{
|
{
|
||||||
if (_pendingInitialResetHost is not { } host)
|
if (_pendingInitialReset is not { } pending)
|
||||||
return;
|
return;
|
||||||
host.ResetSessionState();
|
pending.Host.ResetSessionState(pending.Generation);
|
||||||
_pendingInitialResetHost = null;
|
_pendingInitialReset = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Schedule(PendingOperation operation)
|
private void Schedule(PendingOperation operation)
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ public sealed record LiveSessionEnteredWorldBindings(
|
||||||
|
|
||||||
public sealed record LiveSessionHostBindings(
|
public sealed record LiveSessionHostBindings(
|
||||||
LiveSessionRoutingFactories Routing,
|
LiveSessionRoutingFactories Routing,
|
||||||
Action Reset,
|
Action<RuntimeGenerationToken> Reset,
|
||||||
LiveSessionSelectionBindings Selection,
|
LiveSessionSelectionBindings Selection,
|
||||||
LiveSessionEnteredWorldBindings EnteredWorld,
|
LiveSessionEnteredWorldBindings EnteredWorld,
|
||||||
Action<string, int, string> Connecting,
|
Action<string, int, string> Connecting,
|
||||||
|
|
@ -84,7 +84,7 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands
|
||||||
private readonly LiveSessionRoutingFactories _routing;
|
private readonly LiveSessionRoutingFactories _routing;
|
||||||
private readonly LiveSessionSelectionBindings _selection;
|
private readonly LiveSessionSelectionBindings _selection;
|
||||||
private readonly LiveSessionEnteredWorldBindings _enteredWorld;
|
private readonly LiveSessionEnteredWorldBindings _enteredWorld;
|
||||||
private readonly Action _reset;
|
private readonly Action<RuntimeGenerationToken> _reset;
|
||||||
private readonly LiveSessionLifecycleHost _lifecycle;
|
private readonly LiveSessionLifecycleHost _lifecycle;
|
||||||
private PendingRouteRollback? _pendingRouteRollback;
|
private PendingRouteRollback? _pendingRouteRollback;
|
||||||
|
|
||||||
|
|
@ -188,13 +188,14 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ResetSessionState()
|
private void ResetSessionState(
|
||||||
|
RuntimeGenerationToken retiringGeneration)
|
||||||
{
|
{
|
||||||
// An incompletely detached route can still deliver callbacks into the
|
// An incompletely detached route can still deliver callbacks into the
|
||||||
// state below. Treat physical route convergence as the same hard
|
// state below. Treat physical route convergence as the same hard
|
||||||
// barrier used by normal LiveSessionBinding teardown.
|
// barrier used by normal LiveSessionBinding teardown.
|
||||||
DrainPendingRouteRollback();
|
DrainPendingRouteRollback();
|
||||||
_reset();
|
_reset(retiringGeneration);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ApplySelection(LiveSessionCharacterSelection selection)
|
private void ApplySelection(LiveSessionCharacterSelection selection)
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ namespace AcDream.Runtime.Session;
|
||||||
|
|
||||||
public sealed record LiveSessionLifecycleBindings(
|
public sealed record LiveSessionLifecycleBindings(
|
||||||
Func<WorldSession, LiveSessionBinding> Bind,
|
Func<WorldSession, LiveSessionBinding> Bind,
|
||||||
Action Reset,
|
Action<RuntimeGenerationToken> Reset,
|
||||||
Action<string, int, string> Connecting,
|
Action<string, int, string> Connecting,
|
||||||
Action Connected,
|
Action Connected,
|
||||||
Action<LiveSessionCharacterSelection> Selected,
|
Action<LiveSessionCharacterSelection> Selected,
|
||||||
|
|
@ -42,7 +42,9 @@ public sealed class LiveSessionLifecycleHost : ILiveSessionLifecycleHost
|
||||||
return binding;
|
return binding;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ResetSessionState() => _bindings.Reset();
|
public void ResetSessionState(
|
||||||
|
RuntimeGenerationToken retiringGeneration) =>
|
||||||
|
_bindings.Reset(retiringGeneration);
|
||||||
|
|
||||||
public void ReportConnecting(string host, int port, string user) =>
|
public void ReportConnecting(string host, int port, string user) =>
|
||||||
_bindings.Connecting(host, port, user);
|
_bindings.Connecting(host, port, user);
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ using AcDream.Core.Selection;
|
||||||
using AcDream.Core.Social;
|
using AcDream.Core.Social;
|
||||||
using AcDream.Core.Spells;
|
using AcDream.Core.Spells;
|
||||||
using AcDream.Core.World;
|
using AcDream.Core.World;
|
||||||
|
using AcDream.Runtime;
|
||||||
using DatReaderWriter.DBObjs;
|
using DatReaderWriter.DBObjs;
|
||||||
|
|
||||||
namespace AcDream.App.Tests.Net;
|
namespace AcDream.App.Tests.Net;
|
||||||
|
|
@ -199,190 +200,107 @@ public sealed class LiveSessionResetPlanTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Manifest_MutatedA_FailedDrain_RetryConvergesWithoutClearingRetainedWorld()
|
public void Manifest_PreservesHostOrderAndForwardsExactRetiringGeneration()
|
||||||
{
|
{
|
||||||
const uint playerA = 0x50000001u;
|
var calls = new List<string>();
|
||||||
const uint objectA = 0x70000001u;
|
RuntimeGenerationToken observed = default;
|
||||||
const uint landblock = 0x0101FFFFu;
|
Action Stage(string name) => () => calls.Add(name);
|
||||||
var staticEntity = Entity(42u, 0u);
|
var plan = LiveSessionResetManifest.Create(new()
|
||||||
var spatial = new GpuWorldState();
|
|
||||||
spatial.AddLandblock(new LoadedLandblock(
|
|
||||||
landblock,
|
|
||||||
new LandBlock(),
|
|
||||||
new List<WorldEntity> { staticEntity }));
|
|
||||||
var live = LiveEntityRuntimeFixture.Create(spatial, new FailingOnceResources());
|
|
||||||
WorldSession.EntitySpawn spawn = Spawn(playerA, 1, 1, 0x01010001u);
|
|
||||||
live.RegisterLiveEntity(spawn);
|
|
||||||
live.MaterializeLiveEntity(
|
|
||||||
playerA,
|
|
||||||
0x01010001u,
|
|
||||||
id => Entity(id, playerA));
|
|
||||||
spatial.MarkPersistent(playerA);
|
|
||||||
|
|
||||||
var objects = new ClientObjectTable();
|
|
||||||
objects.AddOrUpdate(new ClientObject { ObjectId = objectA, Name = "A" });
|
|
||||||
var spells = new Spellbook();
|
|
||||||
spells.OnSpellLearned(123u, 1f);
|
|
||||||
var combat = new CombatState();
|
|
||||||
combat.OnUpdateHealth(objectA, 0.5f);
|
|
||||||
combat.SetCombatMode(CombatMode.Melee);
|
|
||||||
var mana = new ItemManaState();
|
|
||||||
mana.OnQueryItemManaResponse(objectA, 0.5f, valid: true);
|
|
||||||
var localPlayer = new LocalPlayerState(spells);
|
|
||||||
localPlayer.OnVitalUpdate(7u, 1u, 100u, 5u, 80u);
|
|
||||||
var friends = new FriendsState();
|
|
||||||
friends.Apply(new FriendsUpdate(
|
|
||||||
FriendsUpdateType.Full,
|
|
||||||
[new FriendEntry(objectA, "A", true, false, [], [])]));
|
|
||||||
var squelch = new SquelchState();
|
|
||||||
squelch.Replace(new SquelchDatabase(
|
|
||||||
new Dictionary<string, uint> { ["A"] = 1u },
|
|
||||||
new Dictionary<uint, SquelchInfo>(),
|
|
||||||
new SquelchInfo("A", false, new HashSet<uint>())));
|
|
||||||
var turbine = new TurbineChatState();
|
|
||||||
turbine.OnChannelsReceived(1u, 2u, 3u, 4u, 5u, 6u, 7u, 8u, 9u, 10u);
|
|
||||||
var selection = new SelectionState();
|
|
||||||
selection.Select(objectA, SelectionChangeSource.World);
|
|
||||||
var external = new ExternalContainerState();
|
|
||||||
external.RequestOpen(objectA);
|
|
||||||
external.ApplyViewContents(objectA);
|
|
||||||
var chat = new ChatLog();
|
|
||||||
chat.SetLocalPlayerGuid(playerA);
|
|
||||||
chat.OnSystemMessage("retained transcript", 1u);
|
|
||||||
|
|
||||||
uint sessionIdentity = playerA;
|
|
||||||
int savedDefaultSetting = 73;
|
|
||||||
int activeSetting = savedDefaultSetting;
|
|
||||||
var calls = new Dictionary<string, int>(StringComparer.Ordinal);
|
|
||||||
var dirty = ExpectedManifestNames().ToDictionary(
|
|
||||||
static name => name,
|
|
||||||
static _ => true,
|
|
||||||
StringComparer.Ordinal);
|
|
||||||
|
|
||||||
Action Stage(string name, Action? reset = null) => () =>
|
|
||||||
{
|
|
||||||
calls[name] = calls.GetValueOrDefault(name) + 1;
|
|
||||||
reset?.Invoke();
|
|
||||||
dirty[name] = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
LiveSessionResetPlan plan = LiveSessionResetManifest.Create(new()
|
|
||||||
{
|
{
|
||||||
MouseCapture = Stage("mouse capture"),
|
MouseCapture = Stage("mouse capture"),
|
||||||
PlayerPresentation = Stage("player presentation"),
|
PlayerPresentation = Stage("player presentation"),
|
||||||
TeleportTransit = Stage("teleport transit"),
|
TeleportPresentation = Stage("teleport presentation"),
|
||||||
SessionDialogs = Stage("session dialogs"),
|
SessionDialogs = Stage("session dialogs"),
|
||||||
ChatCommandTargets = Stage("chat command targets"),
|
SettingsCharacterContext = Stage("settings character context"),
|
||||||
SettingsCharacterContext = Stage(
|
|
||||||
"settings character context",
|
|
||||||
() => activeSetting = savedDefaultSetting),
|
|
||||||
EquippedChildren = Stage("equipped children"),
|
EquippedChildren = Stage("equipped children"),
|
||||||
ExternalContainer = Stage("external container", () => external.Reset()),
|
InteractionPresentation = Stage("interaction presentation"),
|
||||||
InteractionAndSelection = Stage(
|
|
||||||
"interaction and selection",
|
|
||||||
() => selection.Reset()),
|
|
||||||
InventoryTransactions = Stage("inventory transactions"),
|
|
||||||
SelectionPresentation = Stage("selection presentation"),
|
SelectionPresentation = Stage("selection presentation"),
|
||||||
ObjectTable = Stage("object table", objects.Clear),
|
|
||||||
Spellbook = Stage("spellbook", spells.Clear),
|
|
||||||
MagicRuntime = Stage("magic runtime"),
|
|
||||||
CombatAttack = Stage("combat attack"),
|
|
||||||
CombatState = Stage("combat state", combat.Clear),
|
|
||||||
ItemMana = Stage("item mana", mana.Clear),
|
|
||||||
LocalPlayer = Stage("local player", localPlayer.Clear),
|
|
||||||
Friends = Stage("friends", friends.Clear),
|
|
||||||
Squelch = Stage("squelch", squelch.Clear),
|
|
||||||
TurbineChat = Stage("turbine chat", turbine.Reset),
|
|
||||||
ParticleVisibility = Stage("particle visibility"),
|
ParticleVisibility = Stage("particle visibility"),
|
||||||
InboundEventFifo = Stage("inbound event fifo"),
|
InboundEventFifo = Stage("inbound event fifo"),
|
||||||
LiveLiveness = Stage("live liveness"),
|
LiveLiveness = Stage("live liveness"),
|
||||||
LiveRuntime = Stage(
|
RuntimeGeneration = generation =>
|
||||||
"live runtime",
|
|
||||||
() => LiveSessionEntityRuntimeReset.ClearAndRequireConvergence(live)),
|
|
||||||
RenderSceneProjection = Stage("render scene projection"),
|
|
||||||
SessionIdentity = Stage("session identity", () =>
|
|
||||||
{
|
{
|
||||||
LiveSessionEntityRuntimeReset.RequireConvergence(live);
|
observed = generation;
|
||||||
sessionIdentity = 0u;
|
calls.Add("runtime generation");
|
||||||
chat.ResetSessionIdentity();
|
},
|
||||||
}),
|
SessionIdentityPresentation = _ =>
|
||||||
|
calls.Add("session identity presentation"),
|
||||||
RemoteTeleport = Stage("remote teleport"),
|
RemoteTeleport = Stage("remote teleport"),
|
||||||
NetworkEffects = Stage("network effects"),
|
NetworkEffects = Stage("network effects"),
|
||||||
AnimationHookFrames = Stage("animation hook frames"),
|
AnimationHookFrames = Stage("animation hook frames"),
|
||||||
LivePresentation = Stage("live presentation"),
|
LivePresentation = Stage("live presentation"),
|
||||||
RemoteMovementDiagnostics = Stage("remote movement diagnostics"),
|
RemoteMovementDiagnostics =
|
||||||
|
Stage("remote movement diagnostics"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var retiring = new RuntimeGenerationToken(73);
|
||||||
|
plan.Execute(retiring);
|
||||||
|
|
||||||
Assert.Equal(ExpectedManifestNames(), plan.StageNames);
|
Assert.Equal(ExpectedManifestNames(), plan.StageNames);
|
||||||
AggregateException first = Assert.Throws<AggregateException>(plan.Execute);
|
Assert.Equal(ExpectedManifestNames(), calls);
|
||||||
|
Assert.Equal(retiring, observed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GraphicalResetHost_RetriesExactProjectionBeforeIdentityClears()
|
||||||
|
{
|
||||||
|
const uint player = 0x50000001u;
|
||||||
|
const uint landblock = 0x0101FFFFu;
|
||||||
|
using GameRuntime runtime = GameRuntimeTestFactory.Create();
|
||||||
|
runtime.PlayerIdentity.ServerGuid = player;
|
||||||
|
var spatial = new GpuWorldState();
|
||||||
|
spatial.AddLandblock(new LoadedLandblock(
|
||||||
|
landblock,
|
||||||
|
new LandBlock(),
|
||||||
|
Array.Empty<WorldEntity>()));
|
||||||
|
var live = new LiveEntityRuntime(
|
||||||
|
spatial,
|
||||||
|
new FailingOnceResources(),
|
||||||
|
runtime.EntityObjects);
|
||||||
|
live.RegisterLiveEntity(Spawn(player, 1, 1, 0x01010001u));
|
||||||
|
live.MaterializeLiveEntity(
|
||||||
|
player,
|
||||||
|
0x01010001u,
|
||||||
|
id => Entity(id, player));
|
||||||
|
var host = new GraphicalRuntimeGenerationResetHost(
|
||||||
|
live,
|
||||||
|
static () => { });
|
||||||
|
var retiring = new RuntimeGenerationToken(3);
|
||||||
|
|
||||||
|
RuntimeGenerationResetStageException first =
|
||||||
|
Assert.Throws<RuntimeGenerationResetStageException>(
|
||||||
|
() => runtime.ResetGeneration(retiring, host));
|
||||||
|
|
||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
["live runtime", "session identity"],
|
RuntimeGenerationResetStage.RetireEntities,
|
||||||
first.InnerExceptions
|
first.Stage);
|
||||||
.Cast<LiveSessionResetStageException>()
|
Assert.Equal(player, runtime.PlayerIdentity.ServerGuid);
|
||||||
.Select(static error => error.StageName));
|
|
||||||
Assert.Equal(playerA, sessionIdentity);
|
|
||||||
Assert.Equal(1, live.PendingTeardownCount);
|
Assert.Equal(1, live.PendingTeardownCount);
|
||||||
Assert.Contains(staticEntity, spatial.Entities);
|
Assert.Equal(1, live.MaterializedCount);
|
||||||
Assert.Equal(1, spatial.PersistentGuidCount);
|
|
||||||
|
|
||||||
plan.Execute();
|
runtime.ResetGeneration(retiring, host);
|
||||||
|
|
||||||
Assert.All(ExpectedManifestNames(), name => Assert.Equal(2, calls[name]));
|
Assert.Equal(0u, runtime.PlayerIdentity.ServerGuid);
|
||||||
Assert.DoesNotContain(dirty, static pair => pair.Value);
|
|
||||||
Assert.Equal(0u, sessionIdentity);
|
|
||||||
Assert.Equal(savedDefaultSetting, activeSetting);
|
|
||||||
Assert.Equal(0, live.Count);
|
Assert.Equal(0, live.Count);
|
||||||
Assert.Equal(0, live.PendingTeardownCount);
|
Assert.Equal(0, live.PendingTeardownCount);
|
||||||
Assert.Equal(0, live.MaterializedCount);
|
Assert.Equal(0, live.MaterializedCount);
|
||||||
Assert.Equal(0, spatial.PersistentGuidCount);
|
|
||||||
Assert.Contains(staticEntity, spatial.Entities);
|
|
||||||
Assert.True(spatial.IsLoaded(landblock));
|
|
||||||
Assert.Null(objects.Get(objectA));
|
|
||||||
Assert.Equal(0, spells.LearnedCount);
|
|
||||||
Assert.Equal(CombatMode.NonCombat, combat.CurrentMode);
|
|
||||||
Assert.False(combat.HasHealth(objectA));
|
|
||||||
Assert.False(mana.HasMana(objectA));
|
|
||||||
Assert.Null(localPlayer.Get(LocalPlayerState.VitalKind.Health));
|
|
||||||
Assert.Empty(friends.Snapshot());
|
|
||||||
Assert.Same(SquelchDatabase.Empty, squelch.Snapshot());
|
|
||||||
Assert.False(turbine.Enabled);
|
|
||||||
Assert.Null(selection.SelectedObjectId);
|
|
||||||
Assert.Equal(0u, external.CurrentContainerId);
|
|
||||||
Assert.Equal(1, chat.Count);
|
|
||||||
chat.OnLocalSpeech("A", "after", playerA, isRanged: false);
|
|
||||||
Assert.Equal("A", chat.Snapshot()[1].Sender);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string[] ExpectedManifestNames() =>
|
private static string[] ExpectedManifestNames() =>
|
||||||
[
|
[
|
||||||
"mouse capture",
|
"mouse capture",
|
||||||
"player presentation",
|
"player presentation",
|
||||||
"teleport transit",
|
"teleport presentation",
|
||||||
"session dialogs",
|
"session dialogs",
|
||||||
"chat command targets",
|
|
||||||
"settings character context",
|
"settings character context",
|
||||||
"equipped children",
|
"equipped children",
|
||||||
"external container",
|
"interaction presentation",
|
||||||
"interaction and selection",
|
|
||||||
"inventory transactions",
|
|
||||||
"selection presentation",
|
"selection presentation",
|
||||||
"object table",
|
|
||||||
"spellbook",
|
|
||||||
"magic runtime",
|
|
||||||
"combat attack",
|
|
||||||
"combat state",
|
|
||||||
"item mana",
|
|
||||||
"local player",
|
|
||||||
"friends",
|
|
||||||
"squelch",
|
|
||||||
"turbine chat",
|
|
||||||
"particle visibility",
|
"particle visibility",
|
||||||
"inbound event fifo",
|
"inbound event fifo",
|
||||||
"live liveness",
|
"live liveness",
|
||||||
"live runtime",
|
"runtime generation",
|
||||||
"render scene projection",
|
"session identity presentation",
|
||||||
"session identity",
|
|
||||||
"remote teleport",
|
"remote teleport",
|
||||||
"network effects",
|
"network effects",
|
||||||
"animation hook frames",
|
"animation hook frames",
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ using System.Net;
|
||||||
using AcDream.App.Rendering;
|
using AcDream.App.Rendering;
|
||||||
using AcDream.Core.Net;
|
using AcDream.Core.Net;
|
||||||
using AcDream.Core.Net.Messages;
|
using AcDream.Core.Net.Messages;
|
||||||
|
using AcDream.Runtime;
|
||||||
using AcDream.Runtime.Session;
|
using AcDream.Runtime.Session;
|
||||||
|
|
||||||
namespace AcDream.App.Tests.Net;
|
namespace AcDream.App.Tests.Net;
|
||||||
|
|
@ -92,7 +93,8 @@ public sealed class LiveSessionShutdownIntegrationTests
|
||||||
deactivateCommands: static () => { },
|
deactivateCommands: static () => { },
|
||||||
detachEvents: static () => { });
|
detachEvents: static () => { });
|
||||||
|
|
||||||
public void ResetSessionState() { }
|
public void ResetSessionState(
|
||||||
|
RuntimeGenerationToken retiringGeneration) { }
|
||||||
public void ReportConnecting(string host, int port, string user) { }
|
public void ReportConnecting(string host, int port, string user) { }
|
||||||
public void ReportConnected() { }
|
public void ReportConnected() { }
|
||||||
public void ApplySelectedCharacter(LiveSessionCharacterSelection selection) { }
|
public void ApplySelectedCharacter(LiveSessionCharacterSelection selection) { }
|
||||||
|
|
|
||||||
|
|
@ -945,31 +945,17 @@ public sealed class CurrentGameRuntimeAdapterTests
|
||||||
{
|
{
|
||||||
MouseCapture = noop,
|
MouseCapture = noop,
|
||||||
PlayerPresentation = noop,
|
PlayerPresentation = noop,
|
||||||
TeleportTransit = noop,
|
TeleportPresentation = noop,
|
||||||
SessionDialogs = noop,
|
SessionDialogs = noop,
|
||||||
ChatCommandTargets = noop,
|
|
||||||
SettingsCharacterContext = noop,
|
SettingsCharacterContext = noop,
|
||||||
EquippedChildren = noop,
|
EquippedChildren = noop,
|
||||||
ExternalContainer = noop,
|
InteractionPresentation = noop,
|
||||||
InteractionAndSelection = noop,
|
|
||||||
InventoryTransactions = noop,
|
|
||||||
SelectionPresentation = noop,
|
SelectionPresentation = noop,
|
||||||
ObjectTable = noop,
|
|
||||||
Spellbook = noop,
|
|
||||||
MagicRuntime = noop,
|
|
||||||
CombatAttack = noop,
|
|
||||||
CombatState = noop,
|
|
||||||
ItemMana = noop,
|
|
||||||
LocalPlayer = noop,
|
|
||||||
Friends = noop,
|
|
||||||
Squelch = noop,
|
|
||||||
TurbineChat = noop,
|
|
||||||
ParticleVisibility = noop,
|
ParticleVisibility = noop,
|
||||||
InboundEventFifo = noop,
|
InboundEventFifo = noop,
|
||||||
LiveLiveness = noop,
|
LiveLiveness = noop,
|
||||||
LiveRuntime = noop,
|
RuntimeGeneration = _ => { },
|
||||||
RenderSceneProjection = noop,
|
SessionIdentityPresentation = _ => { },
|
||||||
SessionIdentity = noop,
|
|
||||||
RemoteTeleport = noop,
|
RemoteTeleport = noop,
|
||||||
NetworkEffects = noop,
|
NetworkEffects = noop,
|
||||||
AnimationHookFrames = noop,
|
AnimationHookFrames = noop,
|
||||||
|
|
|
||||||
|
|
@ -850,6 +850,9 @@ public sealed class LocalPlayerTeleportControllerTests
|
||||||
public void ResetSession()
|
public void ResetSession()
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
public void ResetGenerationPresentation()
|
||||||
|
{
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class FakePresentation : ILocalPlayerTeleportPresentation
|
private sealed class FakePresentation : ILocalPlayerTeleportPresentation
|
||||||
|
|
|
||||||
650
tests/AcDream.Runtime.Tests/NoWindowGameRuntimeHostTests.cs
Normal file
650
tests/AcDream.Runtime.Tests/NoWindowGameRuntimeHostTests.cs
Normal file
|
|
@ -0,0 +1,650 @@
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.Core.Items;
|
||||||
|
using AcDream.Core.Net;
|
||||||
|
using AcDream.Core.Net.Messages;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
using AcDream.Runtime.Entities;
|
||||||
|
using AcDream.Runtime.Tests.Support;
|
||||||
|
using AcDream.Runtime.World;
|
||||||
|
|
||||||
|
namespace AcDream.Runtime.Tests;
|
||||||
|
|
||||||
|
public sealed class NoWindowGameRuntimeHostTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void RootHostRunsPopulatedGenerationWithoutPresentationAssembly()
|
||||||
|
{
|
||||||
|
var host = new NoWindowGameRuntimeHost();
|
||||||
|
|
||||||
|
RuntimeSessionStartResult started = host.Start();
|
||||||
|
Assert.Equal(RuntimeSessionStartStatus.Connected, started.Status);
|
||||||
|
Assert.Equal(0x50000001u, host.Runtime.PlayerIdentity.ServerGuid);
|
||||||
|
Assert.True(host.Deliver(runtime =>
|
||||||
|
{
|
||||||
|
runtime.EntityObjects.RegisterEntity(
|
||||||
|
Spawn(0x70000001u, 1));
|
||||||
|
runtime.EntityObjects.Objects.AddOrUpdate(new ClientObject
|
||||||
|
{
|
||||||
|
ObjectId = 0x80000001u,
|
||||||
|
Name = "fixture",
|
||||||
|
ContainerId = runtime.PlayerIdentity.ServerGuid,
|
||||||
|
});
|
||||||
|
runtime.CommunicationOwner.Chat.OnSystemMessage(
|
||||||
|
"fixture",
|
||||||
|
1u);
|
||||||
|
}));
|
||||||
|
host.Advance(1d / 30d);
|
||||||
|
RuntimeStateCheckpoint populated =
|
||||||
|
host.Runtime.CaptureCheckpoint();
|
||||||
|
Assert.Equal(1, populated.EntityCount);
|
||||||
|
Assert.Equal(1, populated.InventoryObjectCount);
|
||||||
|
Assert.Equal(1, populated.ChatCount);
|
||||||
|
|
||||||
|
RuntimeTeardownAcknowledgement stopped = host.Stop();
|
||||||
|
|
||||||
|
Assert.True(stopped.IsComplete);
|
||||||
|
Assert.Equal(0u, host.Runtime.PlayerIdentity.ServerGuid);
|
||||||
|
Assert.Equal(0, host.Runtime.Entities.Count);
|
||||||
|
Assert.Equal(0, host.Runtime.Inventory.ObjectCount);
|
||||||
|
Assert.False(host.Deliver(_ =>
|
||||||
|
throw new InvalidOperationException("old route leaked")));
|
||||||
|
Assert.False(host.Execute(_ =>
|
||||||
|
throw new InvalidOperationException("old command leaked")));
|
||||||
|
Assert.Equal(1, host.ProjectionRetirementCount);
|
||||||
|
Assert.Equal(2, host.ProjectionDrainCount);
|
||||||
|
Assert.Equal(2, host.ProjectionCompletionCount);
|
||||||
|
|
||||||
|
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));
|
||||||
|
|
||||||
|
host.Dispose();
|
||||||
|
Assert.True(host.Runtime.CaptureOwnership().IsConverged);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ReconnectReusesRootButNotPriorGuidIncarnationOrRoutes()
|
||||||
|
{
|
||||||
|
const uint guid = 0x70000001u;
|
||||||
|
var host = new NoWindowGameRuntimeHost();
|
||||||
|
RuntimeSessionStartResult first = host.Start();
|
||||||
|
RuntimeGenerationToken firstGeneration = first.Generation;
|
||||||
|
Assert.True(host.Deliver(runtime =>
|
||||||
|
runtime.EntityObjects.RegisterEntity(Spawn(guid, 1))));
|
||||||
|
Assert.True(host.Runtime.EntityObjects.Entities.TryGetActive(
|
||||||
|
guid,
|
||||||
|
out RuntimeEntityRecord firstRecord));
|
||||||
|
|
||||||
|
RuntimeSessionStartResult second = host.Reconnect();
|
||||||
|
RuntimeGenerationToken secondGeneration = second.Generation;
|
||||||
|
|
||||||
|
Assert.Equal(RuntimeSessionStartStatus.Connected, second.Status);
|
||||||
|
Assert.NotEqual(firstGeneration, secondGeneration);
|
||||||
|
Assert.False(host.Runtime.EntityObjects.Entities.TryGetActive(
|
||||||
|
guid,
|
||||||
|
out _));
|
||||||
|
Assert.True(host.Deliver(runtime =>
|
||||||
|
runtime.EntityObjects.RegisterEntity(Spawn(guid, 2))));
|
||||||
|
Assert.True(host.Runtime.EntityObjects.Entities.TryGetActive(
|
||||||
|
guid,
|
||||||
|
out RuntimeEntityRecord secondRecord));
|
||||||
|
Assert.NotSame(firstRecord, secondRecord);
|
||||||
|
Assert.Equal((ushort)2, secondRecord.Incarnation);
|
||||||
|
Assert.Equal(0, host.Runtime.EntityObjects.Entities
|
||||||
|
.PendingTeardownCount);
|
||||||
|
Assert.Contains(
|
||||||
|
$"events-:{firstGeneration.Value}",
|
||||||
|
host.LifecycleTrace);
|
||||||
|
Assert.Contains(
|
||||||
|
$"commands-:{firstGeneration.Value}",
|
||||||
|
host.LifecycleTrace);
|
||||||
|
|
||||||
|
host.Dispose();
|
||||||
|
Assert.True(host.Runtime.CaptureOwnership().IsConverged);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DeterministicLifecycleUsesOneRootAcrossGameplayPortalAndReconnect()
|
||||||
|
{
|
||||||
|
const uint localGuid = 0x50000001u;
|
||||||
|
const uint reusedGuid = 0x70000001u;
|
||||||
|
const uint usableGuid = 0x70000002u;
|
||||||
|
const uint containerGuid = 0x80000001u;
|
||||||
|
const uint itemGuid = 0x80000002u;
|
||||||
|
const string passwordMarker = "credential-must-not-appear";
|
||||||
|
var host = new NoWindowGameRuntimeHost(
|
||||||
|
password: passwordMarker);
|
||||||
|
|
||||||
|
RuntimeStateCheckpoint empty = host.CaptureCheckpoint();
|
||||||
|
Assert.Equal(RuntimeLifecycleState.Constructed, empty.Lifecycle);
|
||||||
|
Assert.Equal(0, empty.EntityCount);
|
||||||
|
Assert.Equal(0, empty.InventoryObjectCount);
|
||||||
|
Assert.Equal(0, empty.ChatCount);
|
||||||
|
|
||||||
|
RuntimeSessionStartResult first = host.Start();
|
||||||
|
RuntimeGenerationToken firstGeneration = first.Generation;
|
||||||
|
Assert.Equal(RuntimeSessionStartStatus.Connected, first.Status);
|
||||||
|
Assert.True(host.Deliver(firstGeneration, runtime =>
|
||||||
|
{
|
||||||
|
runtime.EntityObjects.RegisterEntity(Spawn(localGuid, 1));
|
||||||
|
runtime.EntityObjects.RegisterEntity(Spawn(reusedGuid, 1));
|
||||||
|
runtime.EntityObjects.RegisterEntity(Spawn(usableGuid, 1));
|
||||||
|
runtime.EntityObjects.Objects.AddOrUpdate(new ClientObject
|
||||||
|
{
|
||||||
|
ObjectId = containerGuid,
|
||||||
|
Name = "fixture pack",
|
||||||
|
ContainerId = localGuid,
|
||||||
|
});
|
||||||
|
runtime.EntityObjects.Objects.AddContainer(new Container
|
||||||
|
{
|
||||||
|
ObjectId = containerGuid,
|
||||||
|
Capacity = 24,
|
||||||
|
});
|
||||||
|
runtime.EntityObjects.Objects.AddOrUpdate(new ClientObject
|
||||||
|
{
|
||||||
|
ObjectId = itemGuid,
|
||||||
|
Name = "fixture item",
|
||||||
|
ContainerId = localGuid,
|
||||||
|
StackSize = 3,
|
||||||
|
StackSizeMax = 10,
|
||||||
|
});
|
||||||
|
Assert.True(runtime.EntityObjects.Objects.ApplyServerMove(
|
||||||
|
itemGuid,
|
||||||
|
containerGuid,
|
||||||
|
newWielderId: 0u,
|
||||||
|
newSlot: 0));
|
||||||
|
runtime.CommunicationOwner.Chat.OnSystemMessage(
|
||||||
|
"fixture system",
|
||||||
|
1u);
|
||||||
|
runtime.CommunicationOwner.Chat.OnLocalSpeech(
|
||||||
|
"Runtime",
|
||||||
|
"fixture speech",
|
||||||
|
localGuid,
|
||||||
|
isRanged: false);
|
||||||
|
runtime.CharacterOwner.LocalPlayer.OnVitalUpdate(
|
||||||
|
vitalId: 1u,
|
||||||
|
ranks: 100u,
|
||||||
|
start: 100u,
|
||||||
|
xp: 50u,
|
||||||
|
current: 95u);
|
||||||
|
runtime.EnvironmentOwner.Initialize(
|
||||||
|
new RuntimeWorldEnvironmentDefinition(
|
||||||
|
originOffsetTicks: 0d,
|
||||||
|
sourceTickSize: 1d,
|
||||||
|
lightTickSize: 1d,
|
||||||
|
dayGroups: null));
|
||||||
|
runtime.EnvironmentOwner.SynchronizeFromServer(1234d);
|
||||||
|
}));
|
||||||
|
|
||||||
|
Assert.True(host.Move());
|
||||||
|
Assert.True(host.Use(usableGuid));
|
||||||
|
Assert.True(host.Attack());
|
||||||
|
Assert.True(host.CastFixtureSpell());
|
||||||
|
Assert.True(host.AdvanceProjectile());
|
||||||
|
Assert.True(host.CompletePortal());
|
||||||
|
host.Advance(1d / 30d);
|
||||||
|
|
||||||
|
RuntimeStateCheckpoint populated = host.CaptureCheckpoint();
|
||||||
|
Assert.Equal(RuntimeLifecycleState.InWorld, populated.Lifecycle);
|
||||||
|
Assert.Equal(4, populated.EntityCount);
|
||||||
|
Assert.Equal(2, populated.InventoryObjectCount);
|
||||||
|
Assert.Equal(1, populated.InventoryContainerCount);
|
||||||
|
Assert.Equal(2, populated.ChatCount);
|
||||||
|
Assert.Equal(1, populated.Character.LearnedSpellCount);
|
||||||
|
Assert.True(populated.Movement.AutoRunActive);
|
||||||
|
Assert.True(populated.Portal.Completed);
|
||||||
|
Assert.True(populated.Portal.WorldViewportObserved);
|
||||||
|
Assert.Equal(0, populated.TransitOwnership.HostProjectionCount);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
[
|
||||||
|
"movement:run",
|
||||||
|
$"use:{usableGuid:X8}",
|
||||||
|
"attack:prepare",
|
||||||
|
"attack:Medium:0.5",
|
||||||
|
"cast:stop",
|
||||||
|
"cast:1",
|
||||||
|
"cast:busy",
|
||||||
|
"projectile:ack",
|
||||||
|
],
|
||||||
|
host.GameplayTrace);
|
||||||
|
RuntimeTraceEntry[] firstTrace = host.Trace.Entries
|
||||||
|
.Where(entry =>
|
||||||
|
entry.Stamp.Generation == firstGeneration)
|
||||||
|
.ToArray();
|
||||||
|
Assert.Contains(
|
||||||
|
firstTrace,
|
||||||
|
static entry => entry.Kind == RuntimeTraceKind.Lifecycle);
|
||||||
|
Assert.Contains(
|
||||||
|
firstTrace,
|
||||||
|
static entry => entry.Kind == RuntimeTraceKind.Inventory
|
||||||
|
&& entry.Code == (int)RuntimeInventoryChange.Moved);
|
||||||
|
Assert.Contains(
|
||||||
|
firstTrace,
|
||||||
|
static entry => entry.Kind == RuntimeTraceKind.Movement);
|
||||||
|
Assert.Contains(
|
||||||
|
firstTrace,
|
||||||
|
static entry => entry.Kind == RuntimeTraceKind.Command
|
||||||
|
&& entry.Code >> 16
|
||||||
|
== (int)RuntimeCommandDomain.Magic);
|
||||||
|
Assert.Equal(
|
||||||
|
5,
|
||||||
|
firstTrace.Count(static entry =>
|
||||||
|
entry.Kind == RuntimeTraceKind.Portal));
|
||||||
|
AssertStrictlyOrdered(firstTrace);
|
||||||
|
|
||||||
|
Assert.True(host.Runtime.EntityObjects.Entities.TryGetActive(
|
||||||
|
reusedGuid,
|
||||||
|
out RuntimeEntityRecord firstRecord));
|
||||||
|
RuntimeSessionStartResult second = host.Reconnect();
|
||||||
|
RuntimeGenerationToken secondGeneration = second.Generation;
|
||||||
|
Assert.Equal(RuntimeSessionStartStatus.Connected, second.Status);
|
||||||
|
Assert.NotEqual(firstGeneration, secondGeneration);
|
||||||
|
Assert.False(host.Deliver(
|
||||||
|
firstGeneration,
|
||||||
|
_ => throw new InvalidOperationException(
|
||||||
|
"retired inbound route ran")));
|
||||||
|
Assert.False(host.Execute(
|
||||||
|
firstGeneration,
|
||||||
|
_ => throw new InvalidOperationException(
|
||||||
|
"retired command route ran")));
|
||||||
|
|
||||||
|
RuntimeStateCheckpoint reset = host.Runtime.CaptureCheckpoint();
|
||||||
|
Assert.Equal(0, reset.EntityCount);
|
||||||
|
Assert.Equal(0, reset.InventoryObjectCount);
|
||||||
|
// Retail's end-character path resets active communication targets and
|
||||||
|
// identity but does not issue ClearChatBuffer; bounded chat history is
|
||||||
|
// therefore retained by this reusable root.
|
||||||
|
Assert.Equal(2, reset.ChatCount);
|
||||||
|
Assert.Equal(0, reset.Character.LearnedSpellCount);
|
||||||
|
Assert.Equal(0u, reset.Actions.SelectedObjectId);
|
||||||
|
Assert.Equal(RuntimePortalSnapshot.Idle, reset.Portal);
|
||||||
|
Assert.False(reset.Movement.AutoRunActive);
|
||||||
|
Assert.True(reset.EnvironmentOwnership.IsInitialized);
|
||||||
|
|
||||||
|
Assert.True(host.Deliver(secondGeneration, runtime =>
|
||||||
|
runtime.EntityObjects.RegisterEntity(
|
||||||
|
Spawn(reusedGuid, 2))));
|
||||||
|
Assert.True(host.Runtime.EntityObjects.Entities.TryGetActive(
|
||||||
|
reusedGuid,
|
||||||
|
out RuntimeEntityRecord secondRecord));
|
||||||
|
Assert.NotSame(firstRecord, secondRecord);
|
||||||
|
Assert.Equal((ushort)2, secondRecord.Incarnation);
|
||||||
|
RuntimeStateCheckpoint secondCheckpoint =
|
||||||
|
host.CaptureCheckpoint();
|
||||||
|
Assert.Equal(1, secondCheckpoint.EntityCount);
|
||||||
|
Assert.Equal(0, secondCheckpoint.InventoryObjectCount);
|
||||||
|
Assert.Equal(2, secondCheckpoint.ChatCount);
|
||||||
|
|
||||||
|
Assert.True(host.Stop().IsComplete);
|
||||||
|
AssertStrictlyOrdered(
|
||||||
|
host.Trace.Entries
|
||||||
|
.Where(entry =>
|
||||||
|
entry.Stamp.Generation == secondGeneration)
|
||||||
|
.ToArray());
|
||||||
|
Assert.DoesNotContain(
|
||||||
|
host.LifecycleTrace.Concat(host.GameplayTrace),
|
||||||
|
line => line.Contains(
|
||||||
|
passwordMarker,
|
||||||
|
StringComparison.Ordinal));
|
||||||
|
|
||||||
|
host.Dispose();
|
||||||
|
Assert.True(host.Runtime.CaptureOwnership().IsConverged);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(false)]
|
||||||
|
[InlineData(true)]
|
||||||
|
public void PendingPortalCancellationRetiresHostAcknowledgementsBeforeReconnect(
|
||||||
|
bool destinationReady)
|
||||||
|
{
|
||||||
|
var host = new NoWindowGameRuntimeHost();
|
||||||
|
Assert.Equal(
|
||||||
|
RuntimeSessionStartStatus.Connected,
|
||||||
|
host.Start().Status);
|
||||||
|
RuntimeWorldTransitState transit = host.Runtime.TransitOwner;
|
||||||
|
const uint destinationCell = 0x8A020164u;
|
||||||
|
const ushort sequence = 7;
|
||||||
|
|
||||||
|
Assert.True(transit.TryQueueTeleportStart(sequence));
|
||||||
|
Assert.True(transit.ActivateQueuedTeleport());
|
||||||
|
Assert.True(transit.OfferTeleportDestination(
|
||||||
|
new RuntimeTeleportDestination(
|
||||||
|
host.Runtime.PlayerIdentity.ServerGuid,
|
||||||
|
InstanceSequence: 1,
|
||||||
|
PositionSequence: 1,
|
||||||
|
TeleportSequence: sequence,
|
||||||
|
ForcePositionSequence: 1,
|
||||||
|
Position: new Position(
|
||||||
|
destinationCell,
|
||||||
|
Vector3.Zero,
|
||||||
|
Quaternion.Identity)),
|
||||||
|
teleportTimestampAdvanced: true));
|
||||||
|
Assert.True(transit.TryBeginPortalReveal(
|
||||||
|
sequence,
|
||||||
|
destinationCell,
|
||||||
|
out long revealGeneration));
|
||||||
|
Assert.True(transit.TryRegisterHostProjection(
|
||||||
|
revealGeneration,
|
||||||
|
destinationCell,
|
||||||
|
out RuntimeWorldHostProjectionToken projection));
|
||||||
|
Assert.True(transit.AcknowledgeHostProjection(
|
||||||
|
new RuntimeWorldHostAcknowledgement(
|
||||||
|
projection,
|
||||||
|
RuntimeWorldHostAcknowledgementStage
|
||||||
|
.ProjectionRegistered)));
|
||||||
|
if (destinationReady)
|
||||||
|
{
|
||||||
|
Assert.True(transit.AcknowledgeDestinationReadiness(
|
||||||
|
new RuntimeDestinationReadiness(
|
||||||
|
revealGeneration,
|
||||||
|
destinationCell,
|
||||||
|
IsIndoor: true,
|
||||||
|
IsUnhydratable: false,
|
||||||
|
RequiredRenderRadius: 0,
|
||||||
|
IsRenderNeighborhoodReady: true,
|
||||||
|
AreCompositeTexturesReady: true,
|
||||||
|
IsCollisionReady: true)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.True(transit.Cancel(revealGeneration));
|
||||||
|
DrainPendingPortal(transit, projection);
|
||||||
|
Assert.Equal(0, transit.Ownership.HostProjectionCount);
|
||||||
|
|
||||||
|
RuntimeSessionStartResult reconnected = host.Reconnect();
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
RuntimeSessionStartStatus.Connected,
|
||||||
|
reconnected.Status);
|
||||||
|
Assert.Equal(RuntimePortalSnapshot.Idle, transit.Snapshot);
|
||||||
|
host.Dispose();
|
||||||
|
Assert.True(host.Runtime.CaptureOwnership().IsConverged);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ConcurrentRootsKeepCredentialsClockStateAndFailuresIsolated()
|
||||||
|
{
|
||||||
|
const string firstPassword = "first-password-marker";
|
||||||
|
const string secondPassword = "second-password-marker";
|
||||||
|
const uint sharedGuid = 0x70000055u;
|
||||||
|
var first = new NoWindowGameRuntimeHost(
|
||||||
|
user: "first-user",
|
||||||
|
password: firstPassword,
|
||||||
|
characterId: 0x50000011u,
|
||||||
|
characterName: "First");
|
||||||
|
var second = new NoWindowGameRuntimeHost(
|
||||||
|
user: "second-user",
|
||||||
|
password: secondPassword,
|
||||||
|
characterId: 0x50000022u,
|
||||||
|
characterName: "Second");
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
RuntimeSessionStartStatus.Connected,
|
||||||
|
first.Start().Status);
|
||||||
|
Assert.Equal(
|
||||||
|
RuntimeSessionStartStatus.Connected,
|
||||||
|
second.Start().Status);
|
||||||
|
Assert.True(first.Deliver(runtime =>
|
||||||
|
{
|
||||||
|
runtime.EntityObjects.RegisterEntity(Spawn(sharedGuid, 1));
|
||||||
|
runtime.EntityObjects.Objects.AddOrUpdate(new ClientObject
|
||||||
|
{
|
||||||
|
ObjectId = 0x80000011u,
|
||||||
|
Name = "first item",
|
||||||
|
ContainerId = runtime.PlayerIdentity.ServerGuid,
|
||||||
|
});
|
||||||
|
runtime.CommunicationOwner.Chat.OnSystemMessage(
|
||||||
|
"first chat",
|
||||||
|
1u);
|
||||||
|
}));
|
||||||
|
Assert.True(second.Deliver(runtime =>
|
||||||
|
{
|
||||||
|
runtime.EntityObjects.RegisterEntity(Spawn(sharedGuid, 1));
|
||||||
|
runtime.EntityObjects.Objects.AddOrUpdate(new ClientObject
|
||||||
|
{
|
||||||
|
ObjectId = 0x80000022u,
|
||||||
|
Name = "second item",
|
||||||
|
ContainerId = runtime.PlayerIdentity.ServerGuid,
|
||||||
|
});
|
||||||
|
runtime.CommunicationOwner.Chat.OnSystemMessage(
|
||||||
|
"second chat",
|
||||||
|
1u);
|
||||||
|
}));
|
||||||
|
Assert.True(first.CastFixtureSpell());
|
||||||
|
Assert.True(second.CastFixtureSpell());
|
||||||
|
Assert.True(first.AdvanceProjectile(0x70000011u));
|
||||||
|
Assert.True(second.AdvanceProjectile(0x70000022u));
|
||||||
|
Assert.True(first.CompletePortal(0x8A020164u, sequence: 1));
|
||||||
|
Assert.True(second.CompletePortal(0x11340021u, sequence: 2));
|
||||||
|
first.Advance(0.1d);
|
||||||
|
first.Advance(0.2d);
|
||||||
|
second.Advance(0.4d);
|
||||||
|
|
||||||
|
Assert.True(first.Runtime.EntityObjects.Entities.TryGetActive(
|
||||||
|
sharedGuid,
|
||||||
|
out RuntimeEntityRecord firstRecord));
|
||||||
|
Assert.True(second.Runtime.EntityObjects.Entities.TryGetActive(
|
||||||
|
sharedGuid,
|
||||||
|
out RuntimeEntityRecord secondRecord));
|
||||||
|
Assert.NotSame(firstRecord, secondRecord);
|
||||||
|
Assert.Null(first.Runtime.EntityObjects.Objects.Get(0x80000022u));
|
||||||
|
Assert.Null(second.Runtime.EntityObjects.Objects.Get(0x80000011u));
|
||||||
|
Assert.Equal(
|
||||||
|
"first chat",
|
||||||
|
Assert.Single(
|
||||||
|
first.Runtime.CommunicationOwner.Chat.Snapshot()).Text);
|
||||||
|
Assert.Equal(
|
||||||
|
"second chat",
|
||||||
|
Assert.Single(
|
||||||
|
second.Runtime.CommunicationOwner.Chat.Snapshot()).Text);
|
||||||
|
Assert.Equal(2UL, first.Runtime.Clock.FrameNumber);
|
||||||
|
Assert.Equal(1UL, second.Runtime.Clock.FrameNumber);
|
||||||
|
Assert.NotEqual(
|
||||||
|
first.Runtime.Clock.SimulationTimeSeconds,
|
||||||
|
second.Runtime.Clock.SimulationTimeSeconds);
|
||||||
|
Assert.Equal(
|
||||||
|
0x8A020164u,
|
||||||
|
first.Runtime.TransitOwner.Snapshot.DestinationCell);
|
||||||
|
Assert.Equal(
|
||||||
|
0x11340021u,
|
||||||
|
second.Runtime.TransitOwner.Snapshot.DestinationCell);
|
||||||
|
Assert.DoesNotContain(
|
||||||
|
first.LifecycleTrace.Concat(first.GameplayTrace),
|
||||||
|
line => line.Contains(
|
||||||
|
firstPassword,
|
||||||
|
StringComparison.Ordinal)
|
||||||
|
|| line.Contains(
|
||||||
|
secondPassword,
|
||||||
|
StringComparison.Ordinal));
|
||||||
|
Assert.DoesNotContain(
|
||||||
|
second.LifecycleTrace.Concat(second.GameplayTrace),
|
||||||
|
line => line.Contains(
|
||||||
|
firstPassword,
|
||||||
|
StringComparison.Ordinal)
|
||||||
|
|| line.Contains(
|
||||||
|
secondPassword,
|
||||||
|
StringComparison.Ordinal));
|
||||||
|
|
||||||
|
first.Dispose();
|
||||||
|
second.Dispose();
|
||||||
|
Assert.True(first.Runtime.CaptureOwnership().IsConverged);
|
||||||
|
Assert.True(second.Runtime.CaptureOwnership().IsConverged);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ThrowingRootObserverIsContainedAndDoesNotBlockTeardown()
|
||||||
|
{
|
||||||
|
var host = new NoWindowGameRuntimeHost();
|
||||||
|
using IDisposable failure =
|
||||||
|
host.Runtime.Subscribe(new ThrowingObserver());
|
||||||
|
Assert.Equal(
|
||||||
|
RuntimeSessionStartStatus.Connected,
|
||||||
|
host.Start().Status);
|
||||||
|
|
||||||
|
Assert.True(host.Deliver(runtime =>
|
||||||
|
{
|
||||||
|
runtime.EntityObjects.RegisterEntity(
|
||||||
|
Spawn(0x70000077u, 1));
|
||||||
|
runtime.CommunicationOwner.Chat.OnSystemMessage(
|
||||||
|
"observer fixture",
|
||||||
|
1u);
|
||||||
|
}));
|
||||||
|
Assert.True(host.Move());
|
||||||
|
Assert.True(host.CompletePortal());
|
||||||
|
Assert.True(
|
||||||
|
host.Runtime.CaptureOwnership()
|
||||||
|
.Events.DispatchFailureCount >= 4);
|
||||||
|
|
||||||
|
Assert.True(host.Stop().IsComplete);
|
||||||
|
failure.Dispose();
|
||||||
|
host.Dispose();
|
||||||
|
Assert.True(host.Runtime.CaptureOwnership().IsConverged);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AssertStrictlyOrdered(
|
||||||
|
IReadOnlyList<RuntimeTraceEntry> entries)
|
||||||
|
{
|
||||||
|
for (int index = 1; index < entries.Count; index++)
|
||||||
|
{
|
||||||
|
Assert.True(
|
||||||
|
entries[index].Stamp.Sequence
|
||||||
|
> entries[index - 1].Stamp.Sequence,
|
||||||
|
$"trace sequence {entries[index].Stamp.Sequence} "
|
||||||
|
+ $"did not follow {entries[index - 1].Stamp.Sequence}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DrainPendingPortal(
|
||||||
|
RuntimeWorldTransitState transit,
|
||||||
|
RuntimeWorldHostProjectionToken projection)
|
||||||
|
{
|
||||||
|
while (transit.TryGetHostProjection(
|
||||||
|
projection,
|
||||||
|
out RuntimeWorldHostProjectionSnapshot pending))
|
||||||
|
{
|
||||||
|
RuntimeWorldHostAcknowledgementStage stages =
|
||||||
|
pending.PendingAcknowledgements;
|
||||||
|
RuntimeWorldHostAcknowledgementStage stage =
|
||||||
|
(stages & RuntimeWorldHostAcknowledgementStage
|
||||||
|
.SimulationReleaseProjected) != 0
|
||||||
|
? RuntimeWorldHostAcknowledgementStage
|
||||||
|
.SimulationReleaseProjected
|
||||||
|
: (stages & RuntimeWorldHostAcknowledgementStage
|
||||||
|
.DestinationReservationReleased) != 0
|
||||||
|
? RuntimeWorldHostAcknowledgementStage
|
||||||
|
.DestinationReservationReleased
|
||||||
|
: RuntimeWorldHostAcknowledgementStage
|
||||||
|
.TerminalProjected;
|
||||||
|
Assert.NotEqual(
|
||||||
|
RuntimeWorldHostAcknowledgementStage.None,
|
||||||
|
stages);
|
||||||
|
Assert.True(transit.AcknowledgeHostProjection(
|
||||||
|
new RuntimeWorldHostAcknowledgement(
|
||||||
|
projection,
|
||||||
|
stage)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ThrowingObserver : IRuntimeEventObserver
|
||||||
|
{
|
||||||
|
public void OnLifecycle(in RuntimeLifecycleDelta delta) =>
|
||||||
|
throw new InvalidOperationException("observer");
|
||||||
|
public void OnCommand(in RuntimeCommandDelta delta) =>
|
||||||
|
throw new InvalidOperationException("observer");
|
||||||
|
public void OnEntity(in RuntimeEntityDelta delta) =>
|
||||||
|
throw new InvalidOperationException("observer");
|
||||||
|
public void OnInventory(in RuntimeInventoryDelta delta) =>
|
||||||
|
throw new InvalidOperationException("observer");
|
||||||
|
public void OnChat(in RuntimeChatDelta delta) =>
|
||||||
|
throw new InvalidOperationException("observer");
|
||||||
|
public void OnMovement(in RuntimeMovementDelta delta) =>
|
||||||
|
throw new InvalidOperationException("observer");
|
||||||
|
public void OnPortal(in RuntimePortalDelta delta) =>
|
||||||
|
throw new InvalidOperationException("observer");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorldSession.EntitySpawn Spawn(
|
||||||
|
uint guid,
|
||||||
|
ushort incarnation)
|
||||||
|
{
|
||||||
|
var position = new CreateObject.ServerPosition(
|
||||||
|
0x01010001u,
|
||||||
|
10f,
|
||||||
|
10f,
|
||||||
|
5f,
|
||||||
|
1f,
|
||||||
|
0f,
|
||||||
|
0f,
|
||||||
|
0f);
|
||||||
|
var timestamps = new PhysicsTimestamps(
|
||||||
|
Position: 1,
|
||||||
|
Movement: 1,
|
||||||
|
State: 1,
|
||||||
|
Vector: 1,
|
||||||
|
Teleport: 0,
|
||||||
|
ServerControlledMove: 1,
|
||||||
|
ForcePosition: 0,
|
||||||
|
ObjDesc: 1,
|
||||||
|
Instance: incarnation);
|
||||||
|
var physics = new PhysicsSpawnData(
|
||||||
|
RawState: (uint)PhysicsStateFlags.ReportCollisions,
|
||||||
|
Position: position,
|
||||||
|
Movement: null,
|
||||||
|
AnimationFrame: null,
|
||||||
|
SetupTableId: 0x02000001u,
|
||||||
|
MotionTableId: null,
|
||||||
|
SoundTableId: null,
|
||||||
|
PhysicsScriptTableId: null,
|
||||||
|
Parent: null,
|
||||||
|
Children: null,
|
||||||
|
Scale: null,
|
||||||
|
Friction: null,
|
||||||
|
Elasticity: null,
|
||||||
|
Translucency: null,
|
||||||
|
Velocity: null,
|
||||||
|
Acceleration: null,
|
||||||
|
AngularVelocity: null,
|
||||||
|
DefaultScriptType: null,
|
||||||
|
DefaultScriptIntensity: null,
|
||||||
|
Timestamps: timestamps);
|
||||||
|
return new WorldSession.EntitySpawn(
|
||||||
|
guid,
|
||||||
|
position,
|
||||||
|
0x02000001u,
|
||||||
|
Array.Empty<CreateObject.AnimPartChange>(),
|
||||||
|
Array.Empty<CreateObject.TextureChange>(),
|
||||||
|
Array.Empty<CreateObject.SubPaletteSwap>(),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
guid.ToString("X8"),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
PhysicsState: physics.RawState,
|
||||||
|
InstanceSequence: incarnation,
|
||||||
|
MovementSequence: 1,
|
||||||
|
ServerControlSequence: 1,
|
||||||
|
PositionSequence: 1,
|
||||||
|
Physics: physics);
|
||||||
|
}
|
||||||
|
}
|
||||||
359
tests/AcDream.Runtime.Tests/RuntimeGenerationResetTests.cs
Normal file
359
tests/AcDream.Runtime.Tests/RuntimeGenerationResetTests.cs
Normal file
|
|
@ -0,0 +1,359 @@
|
||||||
|
using AcDream.Core.Combat;
|
||||||
|
using AcDream.Core.Items;
|
||||||
|
using AcDream.Core.Net;
|
||||||
|
using AcDream.Core.Net.Messages;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
using AcDream.Core.Selection;
|
||||||
|
using AcDream.Core.Spells;
|
||||||
|
using AcDream.Runtime.Entities;
|
||||||
|
using AcDream.Runtime.Gameplay;
|
||||||
|
|
||||||
|
namespace AcDream.Runtime.Tests;
|
||||||
|
|
||||||
|
public sealed class RuntimeGenerationResetTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void PopulatedResetConvergesEveryCanonicalOwnerAndStampsRetiringGeneration()
|
||||||
|
{
|
||||||
|
using var runtime = Create();
|
||||||
|
const uint player = 0x50000001u;
|
||||||
|
const uint creature = 0x70000001u;
|
||||||
|
const uint item = 0x80000001u;
|
||||||
|
runtime.PlayerIdentity.ServerGuid = player;
|
||||||
|
runtime.EntityObjects.RegisterEntity(Spawn(creature, 3));
|
||||||
|
runtime.EntityObjects.Objects.AddOrUpdate(new ClientObject
|
||||||
|
{
|
||||||
|
ObjectId = item,
|
||||||
|
Name = "item",
|
||||||
|
ContainerId = player,
|
||||||
|
});
|
||||||
|
runtime.InventoryOwner.ExternalContainers.RequestOpen(creature);
|
||||||
|
runtime.InventoryOwner.ExternalContainers.ApplyViewContents(creature);
|
||||||
|
runtime.InventoryOwner.ItemMana.OnQueryItemManaResponse(
|
||||||
|
item,
|
||||||
|
0.5f,
|
||||||
|
valid: true);
|
||||||
|
runtime.CharacterOwner.Spellbook.OnSpellLearned(123u, 1f);
|
||||||
|
runtime.CharacterOwner.LocalPlayer.OnVitalUpdate(
|
||||||
|
7u,
|
||||||
|
1u,
|
||||||
|
100u,
|
||||||
|
5u,
|
||||||
|
80u);
|
||||||
|
runtime.ActionOwner.Selection.Select(
|
||||||
|
creature,
|
||||||
|
SelectionChangeSource.System);
|
||||||
|
runtime.ActionOwner.Combat.SetCombatMode(CombatMode.Missile);
|
||||||
|
runtime.CommunicationOwner.Chat.SetLocalPlayerGuid(player);
|
||||||
|
runtime.CommunicationOwner.Chat.OnSystemMessage("retained", 1u);
|
||||||
|
_ = runtime.MovementOwner.Execute(
|
||||||
|
RuntimeMovementCommand.ToggleRunLock);
|
||||||
|
var observer = new RecordingObserver();
|
||||||
|
using IDisposable subscription = runtime.Subscribe(observer);
|
||||||
|
var host = new RecordingResetHost(runtime);
|
||||||
|
var retiring = new RuntimeGenerationToken(9);
|
||||||
|
|
||||||
|
runtime.ResetGeneration(retiring, host);
|
||||||
|
|
||||||
|
Assert.Single(host.Retired);
|
||||||
|
Assert.Equal(creature, host.Retired[0].ServerGuid);
|
||||||
|
Assert.Equal(1, host.DrainCalls);
|
||||||
|
Assert.Equal(1, host.CompleteCalls);
|
||||||
|
Assert.All(
|
||||||
|
observer.Entity.Concat(observer.Inventory),
|
||||||
|
stamp => Assert.Equal(retiring, stamp.Generation));
|
||||||
|
Assert.Equal(
|
||||||
|
[1UL, 2UL],
|
||||||
|
observer.Inventory
|
||||||
|
.Concat(observer.Entity)
|
||||||
|
.OrderBy(static stamp => stamp.Sequence)
|
||||||
|
.Select(static stamp => stamp.Sequence));
|
||||||
|
Assert.Equal(0u, runtime.PlayerIdentity.ServerGuid);
|
||||||
|
Assert.Equal(0, runtime.Entities.Count);
|
||||||
|
Assert.Equal(0, runtime.Inventory.ObjectCount);
|
||||||
|
Assert.Equal(0, runtime.CharacterOwner.Spellbook.LearnedCount);
|
||||||
|
Assert.Null(runtime.CharacterOwner.LocalPlayer.Get(
|
||||||
|
AcDream.Core.Player.LocalPlayerState.VitalKind.Health));
|
||||||
|
Assert.Equal(0u, runtime.Actions.Snapshot.SelectedObjectId);
|
||||||
|
Assert.Equal(CombatMode.NonCombat, runtime.Actions.Snapshot.CombatMode);
|
||||||
|
Assert.False(runtime.MovementOwner.AutoRunActive);
|
||||||
|
Assert.Equal(1, runtime.CommunicationOwner.Chat.Count);
|
||||||
|
Assert.Null(
|
||||||
|
runtime.CommunicationOwner.CommandTargets.LastIncomingTellSender);
|
||||||
|
Assert.False(runtime.GenerationReset.CaptureSnapshot().IsActive);
|
||||||
|
|
||||||
|
runtime.ResetGeneration(retiring, host);
|
||||||
|
|
||||||
|
Assert.Single(host.Retired);
|
||||||
|
Assert.Equal(1, host.DrainCalls);
|
||||||
|
Assert.Equal(1, host.CompleteCalls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FailedHostDrainRetriesOnlyTheExactUnfinishedSuffix()
|
||||||
|
{
|
||||||
|
using var runtime = Create();
|
||||||
|
runtime.PlayerIdentity.ServerGuid = 0x50000001u;
|
||||||
|
runtime.EntityObjects.RegisterEntity(Spawn(0x70000001u, 1));
|
||||||
|
runtime.EntityObjects.RegisterEntity(Spawn(0x70000002u, 2));
|
||||||
|
var host = new RecordingResetHost(runtime)
|
||||||
|
{
|
||||||
|
FailDrainOnce = true,
|
||||||
|
};
|
||||||
|
var retiring = new RuntimeGenerationToken(12);
|
||||||
|
|
||||||
|
RuntimeGenerationResetStageException failure =
|
||||||
|
Assert.Throws<RuntimeGenerationResetStageException>(
|
||||||
|
() => runtime.ResetGeneration(retiring, host));
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
RuntimeGenerationResetStage.DrainHostProjection,
|
||||||
|
failure.Stage);
|
||||||
|
Assert.Equal(2, host.Retired.Count);
|
||||||
|
Assert.Equal(1, host.DrainCalls);
|
||||||
|
Assert.Equal(0, host.CompleteCalls);
|
||||||
|
Assert.Equal(0, runtime.EntityObjects.Entities.PendingTeardownCount);
|
||||||
|
Assert.NotEqual(0u, runtime.PlayerIdentity.ServerGuid);
|
||||||
|
RuntimeGenerationResetSnapshot pending =
|
||||||
|
runtime.GenerationReset.CaptureSnapshot();
|
||||||
|
Assert.True(pending.IsActive);
|
||||||
|
Assert.Equal(2, pending.RetirementCursor);
|
||||||
|
|
||||||
|
runtime.ResetGeneration(retiring, host);
|
||||||
|
|
||||||
|
Assert.Equal(2, host.Retired.Count);
|
||||||
|
Assert.Equal(2, host.DrainCalls);
|
||||||
|
Assert.Equal(1, host.CompleteCalls);
|
||||||
|
Assert.Equal(0u, runtime.PlayerIdentity.ServerGuid);
|
||||||
|
Assert.False(runtime.GenerationReset.CaptureSnapshot().IsActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FailedHostCompletionKeepsOldIdentityAndDoesNotReplayDrainOrEntities()
|
||||||
|
{
|
||||||
|
using var runtime = Create();
|
||||||
|
const uint player = 0x50000001u;
|
||||||
|
runtime.PlayerIdentity.ServerGuid = player;
|
||||||
|
runtime.EntityObjects.RegisterEntity(Spawn(0x70000001u, 1));
|
||||||
|
var host = new RecordingResetHost(runtime)
|
||||||
|
{
|
||||||
|
FailCompleteOnce = true,
|
||||||
|
};
|
||||||
|
var retiring = new RuntimeGenerationToken(17);
|
||||||
|
|
||||||
|
RuntimeGenerationResetStageException failure =
|
||||||
|
Assert.Throws<RuntimeGenerationResetStageException>(
|
||||||
|
() => runtime.ResetGeneration(retiring, host));
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
RuntimeGenerationResetStage.CompleteHostProjection,
|
||||||
|
failure.Stage);
|
||||||
|
Assert.Equal(player, runtime.PlayerIdentity.ServerGuid);
|
||||||
|
Assert.Equal(0, runtime.EntityObjects.Entities.Count);
|
||||||
|
Assert.Equal(0, runtime.EntityObjects.Entities.PendingTeardownCount);
|
||||||
|
Assert.Single(host.Retired);
|
||||||
|
Assert.Equal(1, host.DrainCalls);
|
||||||
|
Assert.Equal(1, host.CompleteCalls);
|
||||||
|
|
||||||
|
runtime.ResetGeneration(retiring, host);
|
||||||
|
|
||||||
|
Assert.Single(host.Retired);
|
||||||
|
Assert.Equal(1, host.DrainCalls);
|
||||||
|
Assert.Equal(2, host.CompleteCalls);
|
||||||
|
Assert.Equal(0u, runtime.PlayerIdentity.ServerGuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PendingResetRejectsHostReplacementAndReentrantReset()
|
||||||
|
{
|
||||||
|
using var runtime = Create();
|
||||||
|
runtime.PlayerIdentity.ServerGuid = 0x50000001u;
|
||||||
|
runtime.EntityObjects.RegisterEntity(Spawn(0x70000001u, 1));
|
||||||
|
var retiring = new RuntimeGenerationToken(4);
|
||||||
|
var host = new RecordingResetHost(runtime)
|
||||||
|
{
|
||||||
|
Reenter = true,
|
||||||
|
FailDrainOnce = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.Throws<RuntimeGenerationResetStageException>(
|
||||||
|
() => runtime.ResetGeneration(retiring, host));
|
||||||
|
Assert.IsType<InvalidOperationException>(host.ReentrantFailure);
|
||||||
|
Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
runtime.ResetGeneration(
|
||||||
|
retiring,
|
||||||
|
new RecordingResetHost(runtime)));
|
||||||
|
|
||||||
|
runtime.ResetGeneration(retiring, host);
|
||||||
|
Assert.False(runtime.GenerationReset.CaptureSnapshot().IsActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GameRuntime Create()
|
||||||
|
{
|
||||||
|
var operations = new Operations();
|
||||||
|
return new GameRuntime(new GameRuntimeDependencies(
|
||||||
|
operations,
|
||||||
|
operations,
|
||||||
|
operations,
|
||||||
|
operations));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorldSession.EntitySpawn Spawn(
|
||||||
|
uint guid,
|
||||||
|
ushort incarnation)
|
||||||
|
{
|
||||||
|
var position = new CreateObject.ServerPosition(
|
||||||
|
0x01010001u,
|
||||||
|
10f,
|
||||||
|
10f,
|
||||||
|
5f,
|
||||||
|
1f,
|
||||||
|
0f,
|
||||||
|
0f,
|
||||||
|
0f);
|
||||||
|
var timestamps = new PhysicsTimestamps(
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
incarnation);
|
||||||
|
var physics = new PhysicsSpawnData(
|
||||||
|
RawState: (uint)PhysicsStateFlags.ReportCollisions,
|
||||||
|
Position: position,
|
||||||
|
Movement: null,
|
||||||
|
AnimationFrame: null,
|
||||||
|
SetupTableId: 0x02000001u,
|
||||||
|
MotionTableId: null,
|
||||||
|
SoundTableId: null,
|
||||||
|
PhysicsScriptTableId: null,
|
||||||
|
Parent: null,
|
||||||
|
Children: null,
|
||||||
|
Scale: null,
|
||||||
|
Friction: null,
|
||||||
|
Elasticity: null,
|
||||||
|
Translucency: null,
|
||||||
|
Velocity: null,
|
||||||
|
Acceleration: null,
|
||||||
|
AngularVelocity: null,
|
||||||
|
DefaultScriptType: null,
|
||||||
|
DefaultScriptIntensity: null,
|
||||||
|
Timestamps: timestamps);
|
||||||
|
return new WorldSession.EntitySpawn(
|
||||||
|
guid,
|
||||||
|
position,
|
||||||
|
0x02000001u,
|
||||||
|
Array.Empty<CreateObject.AnimPartChange>(),
|
||||||
|
Array.Empty<CreateObject.TextureChange>(),
|
||||||
|
Array.Empty<CreateObject.SubPaletteSwap>(),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
guid.ToString("X8"),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
PhysicsState: physics.RawState,
|
||||||
|
InstanceSequence: incarnation,
|
||||||
|
MovementSequence: 1,
|
||||||
|
ServerControlSequence: 1,
|
||||||
|
PositionSequence: 1,
|
||||||
|
Physics: physics);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RecordingResetHost(GameRuntime runtime)
|
||||||
|
: IRuntimeGenerationResetHost
|
||||||
|
{
|
||||||
|
public List<RuntimeEntityRecord> Retired { get; } = [];
|
||||||
|
public int DrainCalls { get; private set; }
|
||||||
|
public int CompleteCalls { get; private set; }
|
||||||
|
public bool FailDrainOnce { get; set; }
|
||||||
|
public bool FailCompleteOnce { get; set; }
|
||||||
|
public bool Reenter { get; set; }
|
||||||
|
public Exception? ReentrantFailure { get; private set; }
|
||||||
|
|
||||||
|
public void RetireEntityProjection(RuntimeEntityRecord entity)
|
||||||
|
{
|
||||||
|
Retired.Add(entity);
|
||||||
|
if (!Reenter)
|
||||||
|
return;
|
||||||
|
Reenter = false;
|
||||||
|
ReentrantFailure = Record.Exception(() =>
|
||||||
|
runtime.ResetGeneration(
|
||||||
|
runtime.GenerationReset
|
||||||
|
.CaptureSnapshot()
|
||||||
|
.RetiringGeneration,
|
||||||
|
this));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void DrainEntityProjectionBoundary()
|
||||||
|
{
|
||||||
|
DrainCalls++;
|
||||||
|
if (!FailDrainOnce)
|
||||||
|
return;
|
||||||
|
FailDrainOnce = false;
|
||||||
|
throw new InvalidOperationException("injected drain failure");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void CompleteEntityProjectionRetirement()
|
||||||
|
{
|
||||||
|
CompleteCalls++;
|
||||||
|
Assert.NotEqual(0u, runtime.PlayerIdentity.ServerGuid);
|
||||||
|
if (!FailCompleteOnce)
|
||||||
|
return;
|
||||||
|
FailCompleteOnce = false;
|
||||||
|
throw new InvalidOperationException("injected completion failure");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RecordingObserver : IRuntimeEventObserver
|
||||||
|
{
|
||||||
|
public List<RuntimeEventStamp> Entity { get; } = [];
|
||||||
|
public List<RuntimeEventStamp> Inventory { get; } = [];
|
||||||
|
|
||||||
|
public void OnLifecycle(in RuntimeLifecycleDelta delta) { }
|
||||||
|
public void OnCommand(in RuntimeCommandDelta delta) { }
|
||||||
|
public void OnEntity(in RuntimeEntityDelta delta) =>
|
||||||
|
Entity.Add(delta.Stamp);
|
||||||
|
public void OnInventory(in RuntimeInventoryDelta delta) =>
|
||||||
|
Inventory.Add(delta.Stamp);
|
||||||
|
public void OnChat(in RuntimeChatDelta delta) { }
|
||||||
|
public void OnMovement(in RuntimeMovementDelta delta) { }
|
||||||
|
public void OnPortal(in RuntimePortalDelta delta) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
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() { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -94,29 +94,28 @@ public sealed class RuntimeSimulationFixtureHostTests
|
||||||
|
|
||||||
public RuntimeOnlySimulationFixtureHost()
|
public RuntimeOnlySimulationFixtureHost()
|
||||||
{
|
{
|
||||||
EntityObjects = new RuntimeEntityObjectLifetime();
|
Runtime = new GameRuntime(new GameRuntimeDependencies(
|
||||||
Inventory = new RuntimeInventoryState(EntityObjects);
|
this,
|
||||||
Character = new RuntimeCharacterState();
|
this,
|
||||||
|
this,
|
||||||
|
this,
|
||||||
|
CombatTime: () => _now));
|
||||||
Character.Spellbook.InstallMetadata(SpellMetadata());
|
Character.Spellbook.InstallMetadata(SpellMetadata());
|
||||||
Character.Spellbook.OnSpellLearned(1u);
|
Character.Spellbook.OnSpellLearned(1u);
|
||||||
Communication = new RuntimeCommunicationState();
|
|
||||||
Actions = new RuntimeActionState(
|
|
||||||
Inventory.Transactions,
|
|
||||||
Character.Spellbook,
|
|
||||||
this,
|
|
||||||
this,
|
|
||||||
this,
|
|
||||||
this,
|
|
||||||
now: () => _now);
|
|
||||||
Movement = new RuntimeLocalPlayerMovementState();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public RuntimeEntityObjectLifetime EntityObjects { get; }
|
public GameRuntime Runtime { get; }
|
||||||
public RuntimeInventoryState Inventory { get; }
|
public RuntimeEntityObjectLifetime EntityObjects =>
|
||||||
public RuntimeCharacterState Character { get; }
|
Runtime.EntityObjects;
|
||||||
public RuntimeCommunicationState Communication { get; }
|
public RuntimeInventoryState Inventory =>
|
||||||
public RuntimeActionState Actions { get; }
|
Runtime.InventoryOwner;
|
||||||
public RuntimeLocalPlayerMovementState Movement { get; }
|
public RuntimeCharacterState Character =>
|
||||||
|
Runtime.CharacterOwner;
|
||||||
|
public RuntimeCommunicationState Communication =>
|
||||||
|
Runtime.CommunicationOwner;
|
||||||
|
public RuntimeActionState Actions => Runtime.ActionOwner;
|
||||||
|
public RuntimeLocalPlayerMovementState Movement =>
|
||||||
|
Runtime.MovementOwner;
|
||||||
public List<string> Trace { get; } = [];
|
public List<string> Trace { get; } = [];
|
||||||
|
|
||||||
public void Move()
|
public void Move()
|
||||||
|
|
@ -219,13 +218,7 @@ public sealed class RuntimeSimulationFixtureHostTests
|
||||||
}
|
}
|
||||||
|
|
||||||
public RuntimeSimulationOwnershipSnapshot CaptureOwnership() =>
|
public RuntimeSimulationOwnershipSnapshot CaptureOwnership() =>
|
||||||
RuntimeSimulationOwnership.Capture(
|
Runtime.CaptureOwnership().Simulation;
|
||||||
EntityObjects,
|
|
||||||
Inventory,
|
|
||||||
Character,
|
|
||||||
Communication,
|
|
||||||
Actions,
|
|
||||||
Movement);
|
|
||||||
|
|
||||||
public bool IsInWorld => true;
|
public bool IsInWorld => true;
|
||||||
public uint LocalPlayerId => 0x50000001u;
|
public uint LocalPlayerId => 0x50000001u;
|
||||||
|
|
@ -297,12 +290,7 @@ public sealed class RuntimeSimulationFixtureHostTests
|
||||||
if (_disposed)
|
if (_disposed)
|
||||||
return;
|
return;
|
||||||
_disposed = true;
|
_disposed = true;
|
||||||
Movement.Dispose();
|
Runtime.Dispose();
|
||||||
Actions.Dispose();
|
|
||||||
Communication.Dispose();
|
|
||||||
Character.Dispose();
|
|
||||||
Inventory.Dispose();
|
|
||||||
EntityObjects.Dispose();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static SpellTable SpellMetadata()
|
private static SpellTable SpellMetadata()
|
||||||
|
|
|
||||||
|
|
@ -157,6 +157,7 @@ public sealed class LiveSessionControllerTests
|
||||||
public int ActivateCount { get; private set; }
|
public int ActivateCount { get; private set; }
|
||||||
public List<TestCommandBus> CommandBuses { get; } = [];
|
public List<TestCommandBus> CommandBuses { get; } = [];
|
||||||
public List<LiveSessionCharacterSelection> Selections { get; } = [];
|
public List<LiveSessionCharacterSelection> Selections { get; } = [];
|
||||||
|
public List<RuntimeGenerationToken> ResetGenerations { get; } = [];
|
||||||
|
|
||||||
public LiveSessionBinding BindSession(WorldSession session)
|
public LiveSessionBinding BindSession(WorldSession session)
|
||||||
{
|
{
|
||||||
|
|
@ -200,10 +201,12 @@ public sealed class LiveSessionControllerTests
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ResetSessionState()
|
public void ResetSessionState(
|
||||||
|
RuntimeGenerationToken retiringGeneration)
|
||||||
{
|
{
|
||||||
calls.Add("reset");
|
calls.Add("reset");
|
||||||
ResetCount++;
|
ResetCount++;
|
||||||
|
ResetGenerations.Add(retiringGeneration);
|
||||||
OnReset?.Invoke();
|
OnReset?.Invoke();
|
||||||
if (FailResetOnce)
|
if (FailResetOnce)
|
||||||
{
|
{
|
||||||
|
|
@ -449,6 +452,34 @@ public sealed class LiveSessionControllerTests
|
||||||
Assert.Same(operations.Sessions[1], controller.CurrentSession);
|
Assert.Same(operations.Sessions[1], controller.CurrentSession);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResetCallbacksCarryTheExactRetiringScopeGeneration()
|
||||||
|
{
|
||||||
|
var calls = new List<string>();
|
||||||
|
var operations = new TestOperations(calls);
|
||||||
|
var host = new TestHost(calls);
|
||||||
|
var controller = new LiveSessionController(operations);
|
||||||
|
|
||||||
|
LiveSessionStartResult first =
|
||||||
|
controller.Start(LiveOptions(), host);
|
||||||
|
RuntimeGenerationToken firstGeneration = controller.Generation;
|
||||||
|
LiveSessionStartResult second =
|
||||||
|
controller.Reconnect(LiveOptions(), host);
|
||||||
|
RuntimeGenerationToken secondGeneration = controller.Generation;
|
||||||
|
controller.Stop();
|
||||||
|
|
||||||
|
Assert.Equal(LiveSessionStartStatus.Connected, first.Status);
|
||||||
|
Assert.Equal(LiveSessionStartStatus.Connected, second.Status);
|
||||||
|
Assert.Equal(
|
||||||
|
[
|
||||||
|
RuntimeGenerationToken.Initial,
|
||||||
|
firstGeneration,
|
||||||
|
secondGeneration,
|
||||||
|
],
|
||||||
|
host.ResetGenerations);
|
||||||
|
Assert.NotEqual(firstGeneration, secondGeneration);
|
||||||
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("detach", "stop")]
|
[InlineData("detach", "stop")]
|
||||||
[InlineData("detach", "dispose")]
|
[InlineData("detach", "dispose")]
|
||||||
|
|
|
||||||
|
|
@ -221,7 +221,7 @@ public sealed class LiveSessionHostTests
|
||||||
Func<WorldSession, ILiveSessionCommandRouting> createCommands) =>
|
Func<WorldSession, ILiveSessionCommandRouting> createCommands) =>
|
||||||
new(controller, new LiveSessionHostBindings(
|
new(controller, new LiveSessionHostBindings(
|
||||||
Routing: new(createEvents, createCommands),
|
Routing: new(createEvents, createCommands),
|
||||||
Reset: () => calls.Add("reset"),
|
Reset: _ => calls.Add("reset"),
|
||||||
Selection: new(
|
Selection: new(
|
||||||
id => calls.Add($"player:{id}"),
|
id => calls.Add($"player:{id}"),
|
||||||
id => calls.Add($"vitals:{id}"),
|
id => calls.Add($"vitals:{id}"),
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ public sealed class LiveSessionLifecycleHostTests
|
||||||
var host = CreateHost(calls);
|
var host = CreateHost(calls);
|
||||||
|
|
||||||
LiveSessionBinding binding = host.BindSession(sessionA);
|
LiveSessionBinding binding = host.BindSession(sessionA);
|
||||||
host.ResetSessionState();
|
host.ResetSessionState(RuntimeGenerationToken.Initial);
|
||||||
host.ReportConnecting("host", 9000, "user");
|
host.ReportConnecting("host", 9000, "user");
|
||||||
host.ReportConnected();
|
host.ReportConnected();
|
||||||
var selection = new LiveSessionCharacterSelection(2, 3u, "toon", "account");
|
var selection = new LiveSessionCharacterSelection(2, 3u, "toon", "account");
|
||||||
|
|
@ -67,7 +67,7 @@ public sealed class LiveSessionLifecycleHostTests
|
||||||
Func<WorldSession, LiveSessionBinding>? bind = null) =>
|
Func<WorldSession, LiveSessionBinding>? bind = null) =>
|
||||||
new(new LiveSessionLifecycleBindings(
|
new(new LiveSessionLifecycleBindings(
|
||||||
Bind: bind ?? (session => CreateBinding(session, calls)),
|
Bind: bind ?? (session => CreateBinding(session, calls)),
|
||||||
Reset: () => calls.Add("reset"),
|
Reset: _ => calls.Add("reset"),
|
||||||
Connecting: (host, port, user) =>
|
Connecting: (host, port, user) =>
|
||||||
calls.Add($"connecting:{host}:{port}:{user}"),
|
calls.Add($"connecting:{host}:{port}:{user}"),
|
||||||
Connected: () => calls.Add("connected"),
|
Connected: () => calls.Add("connected"),
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ public sealed class RuntimeLiveSessionNoWindowTests
|
||||||
new LiveSessionRoutingFactories(
|
new LiveSessionRoutingFactories(
|
||||||
_ => new EventRoute(calls),
|
_ => new EventRoute(calls),
|
||||||
_ => new CommandRoute(calls)),
|
_ => new CommandRoute(calls)),
|
||||||
() => calls.Add("reset"),
|
_ => calls.Add("reset"),
|
||||||
new LiveSessionSelectionBindings(
|
new LiveSessionSelectionBindings(
|
||||||
id => calls.Add($"player:{id}"),
|
id => calls.Add($"player:{id}"),
|
||||||
_ => { },
|
_ => { },
|
||||||
|
|
|
||||||
797
tests/AcDream.Runtime.Tests/Support/NoWindowGameRuntimeHost.cs
Normal file
797
tests/AcDream.Runtime.Tests/Support/NoWindowGameRuntimeHost.cs
Normal file
|
|
@ -0,0 +1,797 @@
|
||||||
|
using System.Net;
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.Core.Combat;
|
||||||
|
using AcDream.Core.Items;
|
||||||
|
using AcDream.Core.Net;
|
||||||
|
using AcDream.Core.Net.Messages;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
using AcDream.Core.Spells;
|
||||||
|
using AcDream.Runtime.Entities;
|
||||||
|
using AcDream.Runtime.Gameplay;
|
||||||
|
using AcDream.Runtime.Physics;
|
||||||
|
using AcDream.Runtime.Session;
|
||||||
|
using AcDream.Runtime.World;
|
||||||
|
|
||||||
|
namespace AcDream.Runtime.Tests.Support;
|
||||||
|
|
||||||
|
internal sealed class NoWindowGameRuntimeHost : IDisposable
|
||||||
|
{
|
||||||
|
private readonly FixtureOperations _operations;
|
||||||
|
private readonly GameplayOperations _gameplay;
|
||||||
|
private readonly ImmediateResetHost _resetHost = new();
|
||||||
|
private readonly IDisposable _hostLease;
|
||||||
|
private readonly IDisposable _traceSubscription;
|
||||||
|
private EventRoute? _events;
|
||||||
|
private CommandRoute? _commands;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public NoWindowGameRuntimeHost(
|
||||||
|
string user = "runtime-user",
|
||||||
|
string password = "runtime-password",
|
||||||
|
uint characterId = 0x50000001u,
|
||||||
|
string characterName = "Runtime")
|
||||||
|
{
|
||||||
|
_operations = new FixtureOperations(
|
||||||
|
characterId,
|
||||||
|
characterName);
|
||||||
|
_gameplay = new GameplayOperations();
|
||||||
|
Runtime = new GameRuntime(new GameRuntimeDependencies(
|
||||||
|
_gameplay,
|
||||||
|
_gameplay,
|
||||||
|
_gameplay,
|
||||||
|
_gameplay,
|
||||||
|
SessionOperations: _operations,
|
||||||
|
CombatTime: () => _gameplay.Now));
|
||||||
|
_gameplay.Bind(Runtime);
|
||||||
|
_hostLease = Runtime.AcquireHostLease("no-window test host");
|
||||||
|
Trace = new RuntimeTraceRecorder();
|
||||||
|
_traceSubscription = Runtime.Subscribe(Trace);
|
||||||
|
Session = new LiveSessionHost(
|
||||||
|
Runtime.Session,
|
||||||
|
new LiveSessionHostBindings(
|
||||||
|
new LiveSessionRoutingFactories(
|
||||||
|
CreateEventRoute,
|
||||||
|
CreateCommandRoute),
|
||||||
|
(generation) =>
|
||||||
|
Runtime.ResetGeneration(generation, _resetHost),
|
||||||
|
new LiveSessionSelectionBindings(
|
||||||
|
id => Runtime.PlayerIdentity.ServerGuid = id,
|
||||||
|
_ => { },
|
||||||
|
Runtime.CommunicationOwner.Chat.SetLocalPlayerGuid,
|
||||||
|
_ => { },
|
||||||
|
_ => { },
|
||||||
|
Runtime.ActionOwner.Combat.Clear),
|
||||||
|
new LiveSessionEnteredWorldBindings(
|
||||||
|
_ => { },
|
||||||
|
() => { },
|
||||||
|
() => { },
|
||||||
|
_ => { },
|
||||||
|
() => { }),
|
||||||
|
(host, port, connectingUser) =>
|
||||||
|
_operations.RecordConnecting(
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
connectingUser),
|
||||||
|
_operations.RecordConnected),
|
||||||
|
new LiveSessionConnectOptions(
|
||||||
|
true,
|
||||||
|
"127.0.0.1",
|
||||||
|
9000,
|
||||||
|
user,
|
||||||
|
password));
|
||||||
|
}
|
||||||
|
|
||||||
|
public GameRuntime Runtime { get; }
|
||||||
|
public LiveSessionHost Session { get; }
|
||||||
|
public RuntimeTraceRecorder Trace { get; }
|
||||||
|
public IReadOnlyList<string> LifecycleTrace => _operations.Trace;
|
||||||
|
public IReadOnlyList<string> GameplayTrace => _gameplay.Trace;
|
||||||
|
public int ProjectionRetirementCount =>
|
||||||
|
_resetHost.ProjectionRetirementCount;
|
||||||
|
public int ProjectionDrainCount => _resetHost.ProjectionDrainCount;
|
||||||
|
public int ProjectionCompletionCount =>
|
||||||
|
_resetHost.ProjectionCompletionCount;
|
||||||
|
|
||||||
|
public RuntimeStateCheckpoint CaptureCheckpoint()
|
||||||
|
{
|
||||||
|
RuntimeStateCheckpoint checkpoint =
|
||||||
|
Runtime.CaptureCheckpoint();
|
||||||
|
Trace.AddCheckpoint(
|
||||||
|
Runtime.EntityObjects.Events.NextStamp(),
|
||||||
|
checkpoint);
|
||||||
|
return checkpoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
public RuntimeSessionStartResult Start()
|
||||||
|
{
|
||||||
|
RuntimeLifecycleState previous = Runtime.Lifecycle.State;
|
||||||
|
RuntimeSessionStartResult result =
|
||||||
|
Session.Start(Runtime.Generation);
|
||||||
|
Runtime.EventSink.EmitLifecycle(
|
||||||
|
previous,
|
||||||
|
Runtime.Lifecycle.State);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public RuntimeSessionStartResult Reconnect()
|
||||||
|
{
|
||||||
|
RuntimeLifecycleState previous = Runtime.Lifecycle.State;
|
||||||
|
RuntimeSessionStartResult result =
|
||||||
|
Session.Reconnect(Runtime.Generation);
|
||||||
|
Runtime.EventSink.EmitLifecycle(
|
||||||
|
previous,
|
||||||
|
Runtime.Lifecycle.State);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public RuntimeTeardownAcknowledgement Stop()
|
||||||
|
{
|
||||||
|
RuntimeLifecycleState previous = Runtime.Lifecycle.State;
|
||||||
|
RuntimeTeardownAcknowledgement result =
|
||||||
|
Session.Stop(Runtime.Generation);
|
||||||
|
Runtime.EventSink.EmitLifecycle(
|
||||||
|
previous,
|
||||||
|
Runtime.Lifecycle.State);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Deliver(Action<GameRuntime> delivery) =>
|
||||||
|
Deliver(Runtime.Generation, delivery);
|
||||||
|
|
||||||
|
public bool Deliver(
|
||||||
|
RuntimeGenerationToken expectedGeneration,
|
||||||
|
Action<GameRuntime> delivery)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(delivery);
|
||||||
|
EventRoute? route = _events;
|
||||||
|
if (route is null
|
||||||
|
|| !route.IsActive
|
||||||
|
|| route.Generation != expectedGeneration
|
||||||
|
|| expectedGeneration != Runtime.Generation)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
delivery(Runtime);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Execute(Action<GameRuntime> command) =>
|
||||||
|
Execute(Runtime.Generation, command);
|
||||||
|
|
||||||
|
public bool Execute(
|
||||||
|
RuntimeGenerationToken expectedGeneration,
|
||||||
|
Action<GameRuntime> command)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(command);
|
||||||
|
CommandRoute? route = _commands;
|
||||||
|
if (route is null
|
||||||
|
|| !route.IsActive
|
||||||
|
|| route.Generation != expectedGeneration
|
||||||
|
|| expectedGeneration != Runtime.Generation)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
command(Runtime);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Move()
|
||||||
|
{
|
||||||
|
return Execute(runtime =>
|
||||||
|
{
|
||||||
|
Require(
|
||||||
|
runtime.MovementOwner.Execute(
|
||||||
|
RuntimeMovementCommand.ToggleRunLock),
|
||||||
|
"movement command was rejected");
|
||||||
|
_gameplay.Trace.Add("movement:run");
|
||||||
|
runtime.EventSink.EmitMovement(
|
||||||
|
runtime.MovementOwner.Snapshot);
|
||||||
|
runtime.EventSink.EmitCommand(
|
||||||
|
RuntimeCommandDomain.Movement,
|
||||||
|
(int)RuntimeMovementCommand.ToggleRunLock,
|
||||||
|
RuntimeCommandStatus.Accepted);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Use(uint serverGuid)
|
||||||
|
{
|
||||||
|
return Execute(runtime =>
|
||||||
|
{
|
||||||
|
ItemUseRequestReservation reservation =
|
||||||
|
runtime.ActionOwner.Transactions
|
||||||
|
.BeginUseRequestReservation();
|
||||||
|
RuntimeInteractionDispatchResult result =
|
||||||
|
runtime.ActionOwner.Transactions.TryDispatchUse(
|
||||||
|
serverGuid,
|
||||||
|
ownedByPlayer: false,
|
||||||
|
useable: true,
|
||||||
|
reservation,
|
||||||
|
_gameplay,
|
||||||
|
out _);
|
||||||
|
Require(
|
||||||
|
result == RuntimeInteractionDispatchResult.Dispatched,
|
||||||
|
$"use command was rejected: {result}");
|
||||||
|
runtime.ActionOwner.Transactions.CompleteUse(0u);
|
||||||
|
runtime.EventSink.EmitCommand(
|
||||||
|
RuntimeCommandDomain.Selection,
|
||||||
|
(int)RuntimeSelectionCommand.UseSelected,
|
||||||
|
RuntimeCommandStatus.Accepted,
|
||||||
|
serverGuid);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Attack()
|
||||||
|
{
|
||||||
|
return Execute(runtime =>
|
||||||
|
{
|
||||||
|
runtime.ActionOwner.Combat.SetCombatMode(CombatMode.Melee);
|
||||||
|
runtime.ActionOwner.CombatAttack.SetDesiredPower(0.5f);
|
||||||
|
runtime.ActionOwner.CombatAttack.PressAttack(
|
||||||
|
AttackHeight.Medium);
|
||||||
|
_gameplay.Now += 0.5d;
|
||||||
|
runtime.ActionOwner.CombatAttack.ReleaseAttack();
|
||||||
|
runtime.EventSink.EmitCommand(
|
||||||
|
RuntimeCommandDomain.Combat,
|
||||||
|
(int)RuntimeCombatCommand.ToggleMode,
|
||||||
|
RuntimeCommandStatus.Accepted);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool CastFixtureSpell()
|
||||||
|
{
|
||||||
|
return Execute(runtime =>
|
||||||
|
{
|
||||||
|
runtime.CharacterOwner.InstallSpellMetadata(
|
||||||
|
FixtureSpellMetadata());
|
||||||
|
runtime.CharacterOwner.Spellbook.OnSpellLearned(1u);
|
||||||
|
CastRequestResult result =
|
||||||
|
runtime.ActionOwner.SpellCast.Cast(1u);
|
||||||
|
Require(
|
||||||
|
result == CastRequestResult.Sent,
|
||||||
|
$"spell command was rejected: {result}");
|
||||||
|
runtime.EventSink.EmitCommand(
|
||||||
|
RuntimeCommandDomain.Magic,
|
||||||
|
operation: 1,
|
||||||
|
RuntimeCommandStatus.Accepted,
|
||||||
|
primaryObjectId: 1u);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool AdvanceProjectile(
|
||||||
|
uint serverGuid = 0x70000021u,
|
||||||
|
ushort incarnation = 1)
|
||||||
|
{
|
||||||
|
return Execute(runtime =>
|
||||||
|
{
|
||||||
|
RuntimeEntityRecord record =
|
||||||
|
runtime.EntityObjects.RegisterEntity(
|
||||||
|
ProjectileSpawn(serverGuid, incarnation)).Canonical
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
"projectile registration produced no canonical record");
|
||||||
|
PhysicsBody body =
|
||||||
|
runtime.EntityObjects.Physics.GetOrCreatePhysicsBody(
|
||||||
|
record,
|
||||||
|
static canonical =>
|
||||||
|
{
|
||||||
|
var created = new PhysicsBody
|
||||||
|
{
|
||||||
|
Position = new Vector3(10f, 20f, 5f),
|
||||||
|
Orientation = Quaternion.Identity,
|
||||||
|
InWorld = true,
|
||||||
|
TransientState =
|
||||||
|
TransientStateFlags.Active,
|
||||||
|
};
|
||||||
|
created.SnapToCell(
|
||||||
|
canonical.FullCellId,
|
||||||
|
created.Position,
|
||||||
|
created.Position);
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
_ = runtime.EntityObjects.Physics.BindProjectile(
|
||||||
|
record,
|
||||||
|
body,
|
||||||
|
new ProjectileCollisionSphere(
|
||||||
|
Vector3.Zero,
|
||||||
|
0.25f));
|
||||||
|
runtime.EntityObjects.Physics.AcknowledgeSpatialProjection(
|
||||||
|
record,
|
||||||
|
spatial: true);
|
||||||
|
|
||||||
|
var updater = new RuntimeProjectilePhysicsUpdater(
|
||||||
|
runtime.EntityObjects.Physics);
|
||||||
|
Require(
|
||||||
|
updater.TryBegin(
|
||||||
|
record,
|
||||||
|
quantum: 0.05f,
|
||||||
|
record.ObjectClockEpoch,
|
||||||
|
externalOwnerValid: null,
|
||||||
|
out RuntimeProjectilePhysicsCommit commit),
|
||||||
|
"projectile update did not begin");
|
||||||
|
_ = updater.Complete(
|
||||||
|
commit,
|
||||||
|
liveCenterX: 1,
|
||||||
|
liveCenterY: 1,
|
||||||
|
_ =>
|
||||||
|
{
|
||||||
|
_gameplay.Trace.Add("projectile:ack");
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool CompletePortal(
|
||||||
|
uint destinationCell = 0x8A020164u,
|
||||||
|
ushort sequence = 1)
|
||||||
|
{
|
||||||
|
return Execute(runtime =>
|
||||||
|
{
|
||||||
|
RuntimeWorldTransitState transit = runtime.TransitOwner;
|
||||||
|
Require(
|
||||||
|
transit.TryQueueTeleportStart(sequence),
|
||||||
|
"portal start was rejected");
|
||||||
|
Require(
|
||||||
|
transit.ActivateQueuedTeleport(),
|
||||||
|
"portal activation was rejected");
|
||||||
|
Require(
|
||||||
|
transit.OfferTeleportDestination(
|
||||||
|
new RuntimeTeleportDestination(
|
||||||
|
runtime.PlayerIdentity.ServerGuid,
|
||||||
|
InstanceSequence: 1,
|
||||||
|
PositionSequence: 1,
|
||||||
|
TeleportSequence: sequence,
|
||||||
|
ForcePositionSequence: 1,
|
||||||
|
Position: new Position(
|
||||||
|
destinationCell,
|
||||||
|
Vector3.Zero,
|
||||||
|
Quaternion.Identity)),
|
||||||
|
teleportTimestampAdvanced: true),
|
||||||
|
"portal destination was rejected");
|
||||||
|
Require(
|
||||||
|
transit.TryBeginPortalReveal(
|
||||||
|
sequence,
|
||||||
|
destinationCell,
|
||||||
|
out long revealGeneration),
|
||||||
|
"portal reveal was rejected");
|
||||||
|
EmitPortal(runtime);
|
||||||
|
Require(
|
||||||
|
transit.TryRegisterHostProjection(
|
||||||
|
revealGeneration,
|
||||||
|
destinationCell,
|
||||||
|
out RuntimeWorldHostProjectionToken projection),
|
||||||
|
"portal projection registration was rejected");
|
||||||
|
Acknowledge(
|
||||||
|
transit,
|
||||||
|
projection,
|
||||||
|
RuntimeWorldHostAcknowledgementStage
|
||||||
|
.ProjectionRegistered);
|
||||||
|
bool indoor = (destinationCell & 0xFFFFu) >= 0x0100u;
|
||||||
|
Require(
|
||||||
|
transit.AcknowledgeDestinationReadiness(
|
||||||
|
new RuntimeDestinationReadiness(
|
||||||
|
revealGeneration,
|
||||||
|
destinationCell,
|
||||||
|
indoor,
|
||||||
|
IsUnhydratable: false,
|
||||||
|
RequiredRenderRadius: indoor ? 0 : 1,
|
||||||
|
IsRenderNeighborhoodReady: true,
|
||||||
|
AreCompositeTexturesReady: true,
|
||||||
|
IsCollisionReady: true)),
|
||||||
|
"portal readiness was rejected");
|
||||||
|
EmitPortal(runtime);
|
||||||
|
Require(
|
||||||
|
transit.AcknowledgePortalMaterialized(
|
||||||
|
revealGeneration,
|
||||||
|
sequence,
|
||||||
|
destinationCell),
|
||||||
|
"portal materialization was rejected");
|
||||||
|
EmitPortal(runtime);
|
||||||
|
DrainPortalAcknowledgements(transit, projection);
|
||||||
|
Require(
|
||||||
|
transit.RequireDestinationReservationRelease(
|
||||||
|
projection),
|
||||||
|
"portal reservation release was rejected");
|
||||||
|
DrainPortalAcknowledgements(transit, projection);
|
||||||
|
Require(
|
||||||
|
transit.AcknowledgeWorldViewportVisible(
|
||||||
|
revealGeneration),
|
||||||
|
"portal viewport acknowledgement was rejected");
|
||||||
|
EmitPortal(runtime);
|
||||||
|
Require(
|
||||||
|
transit.Complete(revealGeneration),
|
||||||
|
"portal completion was rejected");
|
||||||
|
EmitPortal(runtime);
|
||||||
|
DrainPortalAcknowledgements(transit, projection);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Advance(double deltaSeconds)
|
||||||
|
{
|
||||||
|
_ = Runtime.Clock.Advance(deltaSeconds);
|
||||||
|
Runtime.Session.Tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
return;
|
||||||
|
if (Runtime.Session.CurrentSession is not null)
|
||||||
|
_ = Stop();
|
||||||
|
_traceSubscription.Dispose();
|
||||||
|
_hostLease.Dispose();
|
||||||
|
Runtime.Dispose();
|
||||||
|
_disposed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ILiveSessionEventRouting CreateEventRoute(WorldSession session)
|
||||||
|
{
|
||||||
|
var route = new EventRoute(Runtime.Generation, _operations.Trace);
|
||||||
|
_events = route;
|
||||||
|
return route;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Acknowledge(
|
||||||
|
RuntimeWorldTransitState transit,
|
||||||
|
RuntimeWorldHostProjectionToken projection,
|
||||||
|
RuntimeWorldHostAcknowledgementStage stage) =>
|
||||||
|
Require(
|
||||||
|
transit.AcknowledgeHostProjection(
|
||||||
|
new RuntimeWorldHostAcknowledgement(
|
||||||
|
projection,
|
||||||
|
stage)),
|
||||||
|
$"portal host acknowledgement was rejected: {stage}");
|
||||||
|
|
||||||
|
private static void DrainPortalAcknowledgements(
|
||||||
|
RuntimeWorldTransitState transit,
|
||||||
|
RuntimeWorldHostProjectionToken projection)
|
||||||
|
{
|
||||||
|
while (transit.TryGetHostProjection(
|
||||||
|
projection,
|
||||||
|
out RuntimeWorldHostProjectionSnapshot pending))
|
||||||
|
{
|
||||||
|
RuntimeWorldHostAcknowledgementStage stages =
|
||||||
|
pending.PendingAcknowledgements;
|
||||||
|
if ((stages & RuntimeWorldHostAcknowledgementStage
|
||||||
|
.SimulationReleaseProjected) != 0)
|
||||||
|
{
|
||||||
|
Acknowledge(
|
||||||
|
transit,
|
||||||
|
projection,
|
||||||
|
RuntimeWorldHostAcknowledgementStage
|
||||||
|
.SimulationReleaseProjected);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ((stages & RuntimeWorldHostAcknowledgementStage
|
||||||
|
.DestinationReservationReleased) != 0)
|
||||||
|
{
|
||||||
|
Acknowledge(
|
||||||
|
transit,
|
||||||
|
projection,
|
||||||
|
RuntimeWorldHostAcknowledgementStage
|
||||||
|
.DestinationReservationReleased);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ((stages & RuntimeWorldHostAcknowledgementStage
|
||||||
|
.TerminalProjected) != 0)
|
||||||
|
{
|
||||||
|
Acknowledge(
|
||||||
|
transit,
|
||||||
|
projection,
|
||||||
|
RuntimeWorldHostAcknowledgementStage
|
||||||
|
.TerminalProjected);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EmitPortal(GameRuntime runtime) =>
|
||||||
|
runtime.EventSink.EmitPortal(runtime.TransitOwner.Snapshot);
|
||||||
|
|
||||||
|
private static SpellTable FixtureSpellMetadata()
|
||||||
|
{
|
||||||
|
const string header =
|
||||||
|
"Spell ID,Name,Flags [Hex],IsUntargetted,TargetMask [Hex]";
|
||||||
|
const string row = "1,Runtime Fixture,0x0,true,0x0";
|
||||||
|
return SpellTable.LoadFromReader(
|
||||||
|
new System.IO.StringReader($"{header}\n{row}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorldSession.EntitySpawn ProjectileSpawn(
|
||||||
|
uint guid,
|
||||||
|
ushort incarnation)
|
||||||
|
{
|
||||||
|
var position = new CreateObject.ServerPosition(
|
||||||
|
0x01010001u,
|
||||||
|
10f,
|
||||||
|
20f,
|
||||||
|
5f,
|
||||||
|
1f,
|
||||||
|
0f,
|
||||||
|
0f,
|
||||||
|
0f);
|
||||||
|
var timestamps = new PhysicsTimestamps(
|
||||||
|
Position: 1,
|
||||||
|
Movement: 1,
|
||||||
|
State: 1,
|
||||||
|
Vector: 1,
|
||||||
|
Teleport: 0,
|
||||||
|
ServerControlledMove: 1,
|
||||||
|
ForcePosition: 0,
|
||||||
|
ObjDesc: 1,
|
||||||
|
Instance: incarnation);
|
||||||
|
var physics = new PhysicsSpawnData(
|
||||||
|
RawState: (uint)(
|
||||||
|
PhysicsStateFlags.ReportCollisions
|
||||||
|
| PhysicsStateFlags.Missile),
|
||||||
|
Position: position,
|
||||||
|
Movement: null,
|
||||||
|
AnimationFrame: null,
|
||||||
|
SetupTableId: 0x02000001u,
|
||||||
|
MotionTableId: null,
|
||||||
|
SoundTableId: null,
|
||||||
|
PhysicsScriptTableId: null,
|
||||||
|
Parent: null,
|
||||||
|
Children: null,
|
||||||
|
Scale: null,
|
||||||
|
Friction: null,
|
||||||
|
Elasticity: null,
|
||||||
|
Translucency: null,
|
||||||
|
Velocity: new Vector3(10f, 0f, 0f),
|
||||||
|
Acceleration: null,
|
||||||
|
AngularVelocity: null,
|
||||||
|
DefaultScriptType: null,
|
||||||
|
DefaultScriptIntensity: null,
|
||||||
|
Timestamps: timestamps);
|
||||||
|
return new WorldSession.EntitySpawn(
|
||||||
|
guid,
|
||||||
|
position,
|
||||||
|
0x02000001u,
|
||||||
|
Array.Empty<CreateObject.AnimPartChange>(),
|
||||||
|
Array.Empty<CreateObject.TextureChange>(),
|
||||||
|
Array.Empty<CreateObject.SubPaletteSwap>(),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
"runtime projectile",
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
PhysicsState: physics.RawState,
|
||||||
|
InstanceSequence: incarnation,
|
||||||
|
MovementSequence: 1,
|
||||||
|
ServerControlSequence: 1,
|
||||||
|
PositionSequence: 1,
|
||||||
|
Physics: physics);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Require(bool condition, string message)
|
||||||
|
{
|
||||||
|
if (!condition)
|
||||||
|
throw new InvalidOperationException(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ILiveSessionCommandRouting CreateCommandRoute(
|
||||||
|
WorldSession session)
|
||||||
|
{
|
||||||
|
var route = new CommandRoute(Runtime.Generation, _operations.Trace);
|
||||||
|
_commands = route;
|
||||||
|
return route;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ImmediateResetHost : IRuntimeGenerationResetHost
|
||||||
|
{
|
||||||
|
public int ProjectionRetirementCount { get; private set; }
|
||||||
|
public int ProjectionDrainCount { get; private set; }
|
||||||
|
public int ProjectionCompletionCount { get; private set; }
|
||||||
|
|
||||||
|
public void RetireEntityProjection(RuntimeEntityRecord entity) =>
|
||||||
|
ProjectionRetirementCount++;
|
||||||
|
|
||||||
|
public void DrainEntityProjectionBoundary() =>
|
||||||
|
ProjectionDrainCount++;
|
||||||
|
|
||||||
|
public void CompleteEntityProjectionRetirement() =>
|
||||||
|
ProjectionCompletionCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class EventRoute(
|
||||||
|
RuntimeGenerationToken generation,
|
||||||
|
List<string> trace) : ILiveSessionEventRouting
|
||||||
|
{
|
||||||
|
public RuntimeGenerationToken Generation { get; } = generation;
|
||||||
|
public bool IsActive { get; private set; }
|
||||||
|
|
||||||
|
public void Attach()
|
||||||
|
{
|
||||||
|
IsActive = true;
|
||||||
|
trace.Add($"events+:{Generation.Value}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
IsActive = false;
|
||||||
|
trace.Add($"events-:{Generation.Value}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class CommandRoute(
|
||||||
|
RuntimeGenerationToken generation,
|
||||||
|
List<string> trace) : ILiveSessionCommandRouting
|
||||||
|
{
|
||||||
|
public RuntimeGenerationToken Generation { get; } = generation;
|
||||||
|
public bool IsActive { get; private set; }
|
||||||
|
|
||||||
|
public void Activate()
|
||||||
|
{
|
||||||
|
IsActive = true;
|
||||||
|
trace.Add($"commands+:{Generation.Value}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
IsActive = false;
|
||||||
|
trace.Add($"commands-:{Generation.Value}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FixtureOperations(
|
||||||
|
uint characterId,
|
||||||
|
string characterName) : ILiveSessionOperations
|
||||||
|
{
|
||||||
|
public List<string> Trace { get; } = [];
|
||||||
|
|
||||||
|
public IPEndPoint ResolveEndpoint(string host, int port)
|
||||||
|
{
|
||||||
|
Trace.Add($"resolve:{host}:{port}");
|
||||||
|
return new IPEndPoint(IPAddress.Loopback, port);
|
||||||
|
}
|
||||||
|
|
||||||
|
public WorldSession CreateSession(IPEndPoint endpoint)
|
||||||
|
{
|
||||||
|
Trace.Add("session+");
|
||||||
|
return new WorldSession(endpoint, new FixtureTransport());
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Connect(
|
||||||
|
WorldSession session,
|
||||||
|
string user,
|
||||||
|
string password) =>
|
||||||
|
Trace.Add($"connect:{user}");
|
||||||
|
|
||||||
|
public CharacterList.Parsed GetCharacters(WorldSession session)
|
||||||
|
{
|
||||||
|
Trace.Add("characters");
|
||||||
|
return new CharacterList.Parsed(
|
||||||
|
0u,
|
||||||
|
[new CharacterList.Character(
|
||||||
|
characterId,
|
||||||
|
characterName,
|
||||||
|
0u)],
|
||||||
|
[],
|
||||||
|
11,
|
||||||
|
"NoWindow",
|
||||||
|
true,
|
||||||
|
true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void EnterWorld(
|
||||||
|
WorldSession session,
|
||||||
|
int activeCharacterIndex) =>
|
||||||
|
Trace.Add($"enter:{activeCharacterIndex}");
|
||||||
|
|
||||||
|
public void Tick(WorldSession session) => Trace.Add("tick");
|
||||||
|
|
||||||
|
public void DisposeSession(WorldSession session)
|
||||||
|
{
|
||||||
|
Trace.Add("session-");
|
||||||
|
session.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RecordConnecting(
|
||||||
|
string host,
|
||||||
|
int port,
|
||||||
|
string user) =>
|
||||||
|
Trace.Add($"connecting:{host}:{port}:{user}");
|
||||||
|
|
||||||
|
public void RecordConnected() => Trace.Add("connected");
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FixtureTransport : IWorldSessionTransport
|
||||||
|
{
|
||||||
|
public void Send(ReadOnlySpan<byte> datagram) { }
|
||||||
|
public void Send(IPEndPoint remote, ReadOnlySpan<byte> datagram) { }
|
||||||
|
|
||||||
|
public int Receive(
|
||||||
|
Span<byte> destination,
|
||||||
|
TimeSpan timeout,
|
||||||
|
out IPEndPoint? from)
|
||||||
|
{
|
||||||
|
from = null;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask<NetReceiveResult> ReceiveAsync(
|
||||||
|
Memory<byte> destination,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
throw new OperationCanceledException(cancellationToken);
|
||||||
|
|
||||||
|
public void Dispose() { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class GameplayOperations :
|
||||||
|
IRuntimeInteractionTransport,
|
||||||
|
IRuntimeCombatAttackOperations,
|
||||||
|
IRuntimeCombatTargetOperations,
|
||||||
|
IRuntimeCombatModeOperations,
|
||||||
|
IRuntimeSpellCastOperations
|
||||||
|
{
|
||||||
|
private GameRuntime? _runtime;
|
||||||
|
private uint _sequence;
|
||||||
|
|
||||||
|
public double Now { get; set; } = 10d;
|
||||||
|
public List<string> Trace { get; } = [];
|
||||||
|
|
||||||
|
public void Bind(GameRuntime runtime) =>
|
||||||
|
_runtime = runtime
|
||||||
|
?? throw new ArgumentNullException(nameof(runtime));
|
||||||
|
|
||||||
|
public bool CanStartAttack() => IsInWorld;
|
||||||
|
public void PrepareAttackRequest() =>
|
||||||
|
Trace.Add("attack:prepare");
|
||||||
|
public bool SendAttack(AttackHeight height, float power)
|
||||||
|
{
|
||||||
|
Trace.Add($"attack:{height}:{power:0.0}");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
public void SendCancelAttack() { }
|
||||||
|
public bool IsDualWield => false;
|
||||||
|
public bool PlayerReadyForAttack => true;
|
||||||
|
public bool AutoRepeatAttack => false;
|
||||||
|
public bool AutoTarget => false;
|
||||||
|
public uint? SelectClosestTarget() => null;
|
||||||
|
public bool IsInWorld => _runtime?.Session.IsInWorld == true;
|
||||||
|
public IReadOnlyList<ClientObject> GetOrderedEquipment() => [];
|
||||||
|
public void NotifyExplicitCombatModeRequest() { }
|
||||||
|
public void SendChangeCombatMode(CombatMode mode) =>
|
||||||
|
Trace.Add($"combat:{mode}");
|
||||||
|
public uint LocalPlayerId =>
|
||||||
|
_runtime?.PlayerIdentity.ServerGuid ?? 0u;
|
||||||
|
public bool CanSend => IsInWorld;
|
||||||
|
public bool HasRequiredComponents(uint spellId) => true;
|
||||||
|
public bool IsTargetCompatible(
|
||||||
|
uint targetId,
|
||||||
|
SpellMetadata spell,
|
||||||
|
bool showMessage) => true;
|
||||||
|
public void StopCompletely() =>
|
||||||
|
Trace.Add("cast:stop");
|
||||||
|
public void SendUntargeted(uint spellId) =>
|
||||||
|
Trace.Add($"cast:{spellId}");
|
||||||
|
public void SendTargeted(uint targetId, uint spellId) =>
|
||||||
|
Trace.Add($"cast:{targetId}:{spellId}");
|
||||||
|
public void DisplayMessage(string message) =>
|
||||||
|
Trace.Add($"message:{message}");
|
||||||
|
public void IncrementBusy()
|
||||||
|
{
|
||||||
|
_runtime?.InventoryOwner.Transactions.IncrementBusyCount();
|
||||||
|
Trace.Add("cast:busy");
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TrySendUse(uint serverGuid, out uint sequence)
|
||||||
|
{
|
||||||
|
sequence = ++_sequence;
|
||||||
|
Trace.Add($"use:{serverGuid:X8}");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TrySendPickup(
|
||||||
|
uint itemGuid,
|
||||||
|
uint destinationContainerId,
|
||||||
|
int placement,
|
||||||
|
out uint sequence)
|
||||||
|
{
|
||||||
|
sequence = ++_sequence;
|
||||||
|
Trace.Add(
|
||||||
|
$"pickup:{itemGuid:X8}:{destinationContainerId:X8}:{placement}");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue