567 lines
20 KiB
C#
567 lines
20 KiB
C#
using AcDream.Headless.Configuration;
|
|
using AcDream.Headless.Credentials;
|
|
using AcDream.Headless.Diagnostics;
|
|
using AcDream.Headless.Policies;
|
|
using AcDream.Runtime;
|
|
using AcDream.Runtime.Gameplay;
|
|
using AcDream.Runtime.Session;
|
|
|
|
namespace AcDream.Headless.Hosting;
|
|
|
|
internal sealed class HeadlessSessionHost : IDisposable
|
|
{
|
|
private sealed class SessionCommandRoute(
|
|
ILiveSessionCommandRouting gameplay,
|
|
ILiveSessionCommandRouting commands)
|
|
: ILiveSessionCommandRouting
|
|
{
|
|
private bool _gameplayActive;
|
|
private bool _commandsActive;
|
|
|
|
public void Activate()
|
|
{
|
|
if (_gameplayActive || _commandsActive)
|
|
return;
|
|
gameplay.Activate();
|
|
_gameplayActive = true;
|
|
try
|
|
{
|
|
commands.Activate();
|
|
_commandsActive = true;
|
|
}
|
|
catch
|
|
{
|
|
gameplay.Dispose();
|
|
_gameplayActive = false;
|
|
throw;
|
|
}
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
List<Exception>? failures = null;
|
|
if (_commandsActive)
|
|
{
|
|
try
|
|
{
|
|
commands.Dispose();
|
|
}
|
|
catch (Exception error)
|
|
{
|
|
(failures ??= []).Add(error);
|
|
}
|
|
_commandsActive = false;
|
|
}
|
|
if (_gameplayActive)
|
|
{
|
|
try
|
|
{
|
|
gameplay.Dispose();
|
|
}
|
|
catch (Exception error)
|
|
{
|
|
(failures ??= []).Add(error);
|
|
}
|
|
_gameplayActive = false;
|
|
}
|
|
if (failures is not null)
|
|
{
|
|
throw new AggregateException(
|
|
"Headless command routes did not detach cleanly.",
|
|
failures);
|
|
}
|
|
}
|
|
}
|
|
|
|
private sealed class SessionCommandBridge : IRuntimeSessionCommands
|
|
{
|
|
private HeadlessSessionHost? _owner;
|
|
|
|
internal void Bind(HeadlessSessionHost owner)
|
|
{
|
|
if (_owner is not null)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"The headless session command bridge is already bound.");
|
|
}
|
|
_owner = owner
|
|
?? throw new ArgumentNullException(nameof(owner));
|
|
}
|
|
|
|
public RuntimeSessionStartResult Start(
|
|
RuntimeGenerationToken expectedGeneration) =>
|
|
RequireOwner().StartCore(expectedGeneration, reconnect: false);
|
|
|
|
public RuntimeSessionStartResult Reconnect(
|
|
RuntimeGenerationToken expectedGeneration) =>
|
|
RequireOwner().StartCore(expectedGeneration, reconnect: true);
|
|
|
|
public RuntimeTeardownAcknowledgement Stop(
|
|
RuntimeGenerationToken expectedGeneration) =>
|
|
RequireOwner()._liveSession.Stop(expectedGeneration);
|
|
|
|
private HeadlessSessionHost RequireOwner() =>
|
|
_owner
|
|
?? throw new InvalidOperationException(
|
|
"The headless session command bridge is not bound.");
|
|
}
|
|
|
|
private readonly HeadlessSessionDescriptor _descriptor;
|
|
private readonly HeadlessCredentialSecret _credential;
|
|
private readonly HeadlessDiagnosticWriter _diagnostics;
|
|
private readonly TimeSpan _reconnectQuiescence;
|
|
private readonly TimeProvider _timeProvider;
|
|
private readonly HeadlessGenerationResetHost _resetHost = new();
|
|
private readonly IDisposable _hostLease;
|
|
private readonly IHeadlessBotPolicy _policy;
|
|
private readonly IDisposable _policySubscription;
|
|
private readonly LiveSessionHost _liveSession;
|
|
private readonly RuntimeLocalPlayerFrameController _localPlayerFrame;
|
|
private readonly HeadlessProcessContentOwner.HeadlessProcessContentLease?
|
|
_contentLease;
|
|
private int _disposeStage;
|
|
private long _reconnectDeadline;
|
|
private bool _reconnectPending;
|
|
private ulong _stoppedGeneration;
|
|
private string _accountName = string.Empty;
|
|
private bool _disposed;
|
|
|
|
internal HeadlessSessionHost(
|
|
HeadlessSessionDescriptor descriptor,
|
|
HeadlessCredentialSecret credential,
|
|
HeadlessDiagnosticWriter diagnostics,
|
|
ILiveSessionOperations? sessionOperations = null,
|
|
TimeProvider? timeProvider = null,
|
|
TimeSpan? reconnectQuiescence = null,
|
|
HeadlessProcessContentOwner.HeadlessProcessContentLease?
|
|
contentLease = null)
|
|
{
|
|
_descriptor = descriptor
|
|
?? throw new ArgumentNullException(nameof(descriptor));
|
|
_credential = credential
|
|
?? throw new ArgumentNullException(nameof(credential));
|
|
_diagnostics = diagnostics
|
|
?? throw new ArgumentNullException(nameof(diagnostics));
|
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
|
_reconnectQuiescence = reconnectQuiescence
|
|
?? (sessionOperations is null
|
|
? TimeSpan.FromMilliseconds(2500)
|
|
: TimeSpan.Zero);
|
|
if (_reconnectQuiescence < TimeSpan.Zero)
|
|
{
|
|
throw new ArgumentOutOfRangeException(
|
|
nameof(reconnectQuiescence));
|
|
}
|
|
|
|
GameRuntime? runtimeRef = null;
|
|
IDisposable? hostLease = null;
|
|
IHeadlessBotPolicy? policy = null;
|
|
IDisposable? policySubscription = null;
|
|
try
|
|
{
|
|
var gameplay = new HeadlessGameplayOperations();
|
|
var runtime = new GameRuntime(new GameRuntimeDependencies(
|
|
gameplay,
|
|
gameplay,
|
|
gameplay,
|
|
gameplay,
|
|
TimeProvider: _timeProvider,
|
|
Log: message => diagnostics.Message(
|
|
descriptor.Id,
|
|
message),
|
|
SessionOperations: sessionOperations,
|
|
CombatTime: () =>
|
|
runtimeRef?.Clock.SimulationTimeSeconds ?? 0d));
|
|
runtimeRef = runtime;
|
|
if (contentLease is { } content)
|
|
{
|
|
runtime.CharacterOwner.InstallSpellMetadata(
|
|
content.MagicCatalog.SpellTable);
|
|
}
|
|
gameplay.Bind(
|
|
runtime,
|
|
contentLease?.MagicCatalog,
|
|
() => _accountName);
|
|
|
|
var bridge = new SessionCommandBridge();
|
|
var commands = new DirectGameRuntimeCommandAdapter(
|
|
runtime,
|
|
bridge);
|
|
var liveSession = new LiveSessionHost(
|
|
runtime.Session,
|
|
new LiveSessionHostBindings(
|
|
new LiveSessionRoutingFactories(
|
|
CreateEventRoute,
|
|
session => new SessionCommandRoute(
|
|
gameplay.CreateRoute(session),
|
|
commands.CreateRoute(session))),
|
|
generation =>
|
|
runtime.ResetGeneration(generation, _resetHost),
|
|
new LiveSessionSelectionBindings(
|
|
id => runtime.PlayerIdentity.ServerGuid = id,
|
|
_ => { },
|
|
runtime.CommunicationOwner.Chat.SetLocalPlayerGuid,
|
|
_ => { },
|
|
_ => { },
|
|
runtime.ActionOwner.Combat.Clear),
|
|
new LiveSessionEnteredWorldBindings(
|
|
name => ActiveCharacterName = name,
|
|
() => { },
|
|
() => { },
|
|
_ => { },
|
|
() => { }),
|
|
(host, port, user) =>
|
|
diagnostics.Message(
|
|
descriptor.Id,
|
|
$"connecting:{host}:{port}:{user}",
|
|
runtime.Generation.Value),
|
|
() => diagnostics.Message(
|
|
descriptor.Id,
|
|
"connected",
|
|
runtime.Generation.Value)));
|
|
|
|
Runtime = runtime;
|
|
Commands = commands;
|
|
_liveSession = liveSession;
|
|
_localPlayerFrame =
|
|
runtime.CreateLocalPlayerFrameController(
|
|
new HeadlessLocalPlayerFrameHost(
|
|
runtime,
|
|
liveSession),
|
|
new HeadlessMovementInputSource(
|
|
runtime.MovementOwner));
|
|
_contentLease = contentLease;
|
|
bridge.Bind(this);
|
|
|
|
hostLease = runtime.AcquireHostLease(
|
|
$"headless:{descriptor.Id}");
|
|
policy =
|
|
HeadlessBotPolicyFactory.Create(descriptor.Policy.Id);
|
|
policySubscription = runtime.Subscribe(policy);
|
|
diagnostics.Lifecycle(
|
|
descriptor.Id,
|
|
"constructed",
|
|
runtime);
|
|
|
|
_hostLease = hostLease;
|
|
_policy = policy;
|
|
_policySubscription = policySubscription;
|
|
}
|
|
catch
|
|
{
|
|
policySubscription?.Dispose();
|
|
policy?.Dispose();
|
|
hostLease?.Dispose();
|
|
contentLease?.Dispose();
|
|
credential.Dispose();
|
|
runtimeRef?.Dispose();
|
|
throw;
|
|
}
|
|
}
|
|
|
|
internal GameRuntime Runtime { get; }
|
|
internal DirectGameRuntimeCommandAdapter Commands { get; }
|
|
internal string SessionId => _descriptor.Id;
|
|
internal string ActiveCharacterName { get; private set; } =
|
|
string.Empty;
|
|
internal bool IsPolicyComplete => _policy.IsComplete;
|
|
internal bool IsReconnectPending => _reconnectPending;
|
|
internal HeadlessProcessContentOwner.HeadlessProcessContentLease?
|
|
Content => _contentLease;
|
|
internal long ReconnectDeadline => _reconnectPending
|
|
? _reconnectDeadline
|
|
: throw new InvalidOperationException(
|
|
"The headless session has no pending reconnect.");
|
|
|
|
internal RuntimeSessionStartResult Start() =>
|
|
Commands.Session.Start(Runtime.Generation);
|
|
|
|
internal RuntimeSessionStartResult Reconnect() =>
|
|
Commands.Session.Reconnect(Runtime.Generation);
|
|
|
|
internal void Tick(double deltaSeconds)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
if (_reconnectPending)
|
|
return;
|
|
_ = Runtime.Clock.Advance(deltaSeconds);
|
|
_localPlayerFrame.AdvanceBeforeNetwork(
|
|
checked((float)deltaSeconds));
|
|
Runtime.Session.Tick();
|
|
_localPlayerFrame.RunPostNetworkCommandPhase();
|
|
Runtime.ActionOwner.CombatAttack.Tick();
|
|
_policy.Tick(Runtime, Commands);
|
|
}
|
|
|
|
internal RuntimeTeardownAcknowledgement Stop() =>
|
|
Commands.Session.Stop(Runtime.Generation);
|
|
|
|
internal RuntimeSessionStartResult CompletePendingReconnect(
|
|
long nowTimestamp)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
if (!_reconnectPending)
|
|
{
|
|
return new RuntimeSessionStartResult(
|
|
RuntimeSessionStartStatus.Inactive,
|
|
Runtime.Generation);
|
|
}
|
|
if (nowTimestamp < _reconnectDeadline)
|
|
{
|
|
return new RuntimeSessionStartResult(
|
|
RuntimeSessionStartStatus.Deferred,
|
|
Runtime.Generation);
|
|
}
|
|
|
|
_reconnectPending = false;
|
|
_reconnectDeadline = 0L;
|
|
return StartLive(reconnect: true);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed)
|
|
return;
|
|
|
|
while (!_disposed)
|
|
{
|
|
switch (_disposeStage)
|
|
{
|
|
case 0:
|
|
{
|
|
_reconnectPending = false;
|
|
_reconnectDeadline = 0L;
|
|
RuntimeTeardownAcknowledgement stopped = Stop();
|
|
if (!stopped.IsComplete)
|
|
{
|
|
throw stopped.Error
|
|
?? new InvalidOperationException(
|
|
$"Headless session '{_descriptor.Id}' did not complete teardown.");
|
|
}
|
|
_stoppedGeneration =
|
|
stopped.CurrentGeneration.Value;
|
|
_disposeStage++;
|
|
break;
|
|
}
|
|
case 1:
|
|
_diagnostics.Lifecycle(
|
|
_descriptor.Id,
|
|
"stopped",
|
|
Runtime);
|
|
_disposeStage++;
|
|
break;
|
|
case 2:
|
|
_policySubscription.Dispose();
|
|
_disposeStage++;
|
|
break;
|
|
case 3:
|
|
_policy.Dispose();
|
|
_disposeStage++;
|
|
break;
|
|
case 4:
|
|
_hostLease.Dispose();
|
|
_disposeStage++;
|
|
break;
|
|
case 5:
|
|
_credential.Dispose();
|
|
_disposeStage++;
|
|
break;
|
|
case 6:
|
|
Runtime.Dispose();
|
|
_disposeStage++;
|
|
break;
|
|
case 7:
|
|
_contentLease?.Dispose();
|
|
_disposeStage++;
|
|
break;
|
|
case 8:
|
|
_diagnostics.Message(
|
|
_descriptor.Id,
|
|
"disposed",
|
|
_stoppedGeneration);
|
|
_disposeStage++;
|
|
_disposed = true;
|
|
break;
|
|
default:
|
|
throw new InvalidOperationException(
|
|
"Unknown headless session teardown stage.");
|
|
}
|
|
}
|
|
}
|
|
|
|
private RuntimeSessionStartResult StartCore(
|
|
RuntimeGenerationToken expectedGeneration,
|
|
bool reconnect)
|
|
{
|
|
if (expectedGeneration != Runtime.Generation)
|
|
{
|
|
return new RuntimeSessionStartResult(
|
|
RuntimeSessionStartStatus.StaleGeneration,
|
|
Runtime.Generation);
|
|
}
|
|
if (_reconnectPending)
|
|
{
|
|
return new RuntimeSessionStartResult(
|
|
RuntimeSessionStartStatus.Deferred,
|
|
Runtime.Generation);
|
|
}
|
|
|
|
if (reconnect)
|
|
{
|
|
RuntimeTeardownAcknowledgement stopped =
|
|
_liveSession.Stop(expectedGeneration);
|
|
if (!stopped.IsComplete)
|
|
{
|
|
return new RuntimeSessionStartResult(
|
|
RuntimeSessionStartStatus.Failed,
|
|
stopped.CurrentGeneration,
|
|
Error: stopped.Error
|
|
?? new InvalidOperationException(
|
|
"The prior headless session did not quiesce before reconnect."));
|
|
}
|
|
|
|
if (_reconnectQuiescence > TimeSpan.Zero)
|
|
{
|
|
// ACE confirms CharacterLogOff before its account/session
|
|
// index releases the retiring socket. The process scheduler
|
|
// owns this monotonic deadline so other sessions keep moving
|
|
// and no command or worker thread blocks.
|
|
_reconnectDeadline = HeadlessMonotonicTime.Add(
|
|
_timeProvider,
|
|
_timeProvider.GetTimestamp(),
|
|
_reconnectQuiescence);
|
|
_reconnectPending = true;
|
|
_diagnostics.Lifecycle(
|
|
_descriptor.Id,
|
|
"reconnect-deferred",
|
|
Runtime);
|
|
return new RuntimeSessionStartResult(
|
|
RuntimeSessionStartStatus.Deferred,
|
|
Runtime.Generation);
|
|
}
|
|
}
|
|
|
|
return StartLive(reconnect);
|
|
}
|
|
|
|
private RuntimeSessionStartResult StartLive(bool reconnect)
|
|
{
|
|
string password = _credential.Reveal();
|
|
try
|
|
{
|
|
LiveSessionConnectOptions options = new(
|
|
Enabled: true,
|
|
_descriptor.Endpoint.Host,
|
|
_descriptor.Endpoint.Port,
|
|
_descriptor.Account,
|
|
password,
|
|
MapCharacterSelector(_descriptor.Character));
|
|
LiveSessionStartResult result = _liveSession.Start(options);
|
|
if (result.Selection is { } selection)
|
|
_accountName = selection.AccountName;
|
|
RuntimeSessionStartResult converted = Convert(result);
|
|
if (converted.Error is { } error)
|
|
{
|
|
_diagnostics.Failure(
|
|
_descriptor.Id,
|
|
reconnect ? "reconnect" : "start",
|
|
error);
|
|
}
|
|
_diagnostics.Lifecycle(
|
|
_descriptor.Id,
|
|
reconnect ? "reconnect-result" : "start-result",
|
|
Runtime);
|
|
return converted;
|
|
}
|
|
finally
|
|
{
|
|
password = string.Empty;
|
|
}
|
|
}
|
|
|
|
private LiveSessionEventRouter CreateEventRoute(
|
|
AcDream.Core.Net.WorldSession session)
|
|
{
|
|
var entities = new RuntimeLiveEntitySessionController(
|
|
Runtime,
|
|
session,
|
|
message => _diagnostics.Message(
|
|
_descriptor.Id,
|
|
message,
|
|
Runtime.Generation.Value));
|
|
return new LiveSessionEventRouter(
|
|
session,
|
|
entities.CreateSink(),
|
|
new LiveEnvironmentSessionSink(
|
|
change =>
|
|
_ = Runtime.EnvironmentOwner
|
|
.ApplyAdminEnvirons(change),
|
|
Runtime.EnvironmentOwner.SynchronizeFromServer),
|
|
new LiveInventorySessionBindings(
|
|
Runtime.InventoryOwner.Objects,
|
|
() => Runtime.PlayerIdentity.ServerGuid,
|
|
Runtime.InventoryOwner.Shortcuts.Load,
|
|
error =>
|
|
{
|
|
Runtime.InventoryOwner.ExternalContainers
|
|
.ApplyUseDone(error);
|
|
Runtime.ActionOwner.Transactions.CompleteUse(error);
|
|
},
|
|
Runtime.InventoryOwner.ItemMana,
|
|
Runtime.InventoryOwner.ExternalContainers,
|
|
appraisal =>
|
|
Runtime.ActionOwner.Transactions
|
|
.AcceptAppraisalResponse(appraisal.Guid)),
|
|
new LiveCharacterSessionBindings(
|
|
Runtime.ActionOwner.Combat,
|
|
Runtime.CharacterOwner,
|
|
ResolveSkillFormulaBonus: null,
|
|
OnSkillsUpdated: null,
|
|
OnConfirmationRequest: null,
|
|
OnConfirmationDone: null,
|
|
ClientTime: () =>
|
|
Runtime.Clock.SimulationTimeSeconds),
|
|
new LiveSocialSessionBindings(
|
|
Runtime.CommunicationOwner.Chat,
|
|
Runtime.CommunicationOwner.TurbineChat,
|
|
Runtime.CommunicationOwner.Friends,
|
|
Runtime.CommunicationOwner.Squelch));
|
|
}
|
|
|
|
private static LiveSessionCharacterSelector MapCharacterSelector(
|
|
HeadlessCharacterSelector selector) =>
|
|
new(
|
|
selector.Index,
|
|
selector.Id,
|
|
selector.Name);
|
|
|
|
private RuntimeSessionStartResult Convert(
|
|
LiveSessionStartResult result)
|
|
{
|
|
RuntimeSessionStartStatus status = result.Status switch
|
|
{
|
|
LiveSessionStartStatus.Disabled =>
|
|
RuntimeSessionStartStatus.Disabled,
|
|
LiveSessionStartStatus.MissingCredentials =>
|
|
RuntimeSessionStartStatus.MissingCredentials,
|
|
LiveSessionStartStatus.NoCharacters =>
|
|
RuntimeSessionStartStatus.NoCharacters,
|
|
LiveSessionStartStatus.Connected =>
|
|
RuntimeSessionStartStatus.Connected,
|
|
LiveSessionStartStatus.Deferred =>
|
|
RuntimeSessionStartStatus.Deferred,
|
|
LiveSessionStartStatus.Failed =>
|
|
RuntimeSessionStartStatus.Failed,
|
|
_ => throw new ArgumentOutOfRangeException(
|
|
nameof(result),
|
|
result.Status,
|
|
"Unknown live-session start result."),
|
|
};
|
|
return new RuntimeSessionStartResult(
|
|
status,
|
|
Runtime.Generation,
|
|
result.Selection?.CharacterId ?? 0u,
|
|
result.Selection?.CharacterName ?? string.Empty,
|
|
result.Error);
|
|
}
|
|
}
|