feat(headless): complete portable single-session host
This commit is contained in:
parent
fbebb91848
commit
f8cb840fb1
30 changed files with 3571 additions and 32 deletions
414
src/AcDream.Headless/Hosting/HeadlessSessionHost.cs
Normal file
414
src/AcDream.Headless/Hosting/HeadlessSessionHost.cs
Normal file
|
|
@ -0,0 +1,414 @@
|
|||
using System.Diagnostics;
|
||||
using AcDream.Headless.Configuration;
|
||||
using AcDream.Headless.Credentials;
|
||||
using AcDream.Headless.Diagnostics;
|
||||
using AcDream.Headless.Policies;
|
||||
using AcDream.Runtime;
|
||||
using AcDream.Runtime.Session;
|
||||
|
||||
namespace AcDream.Headless.Hosting;
|
||||
|
||||
internal sealed class HeadlessSessionHost : IDisposable
|
||||
{
|
||||
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 HeadlessGenerationResetHost _resetHost = new();
|
||||
private readonly IDisposable _hostLease;
|
||||
private readonly IHeadlessBotPolicy _policy;
|
||||
private readonly IDisposable _policySubscription;
|
||||
private readonly LiveSessionHost _liveSession;
|
||||
private int _disposeStage;
|
||||
private ulong _stoppedGeneration;
|
||||
private bool _disposed;
|
||||
|
||||
internal HeadlessSessionHost(
|
||||
HeadlessSessionDescriptor descriptor,
|
||||
HeadlessCredentialSecret credential,
|
||||
HeadlessDiagnosticWriter diagnostics,
|
||||
ILiveSessionOperations? sessionOperations = null,
|
||||
TimeProvider? timeProvider = null,
|
||||
TimeSpan? reconnectQuiescence = null)
|
||||
{
|
||||
_descriptor = descriptor
|
||||
?? throw new ArgumentNullException(nameof(descriptor));
|
||||
_credential = credential
|
||||
?? throw new ArgumentNullException(nameof(credential));
|
||||
_diagnostics = diagnostics
|
||||
?? throw new ArgumentNullException(nameof(diagnostics));
|
||||
_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;
|
||||
gameplay.Bind(runtime);
|
||||
|
||||
var bridge = new SessionCommandBridge();
|
||||
var commands = new DirectGameRuntimeCommandAdapter(
|
||||
runtime,
|
||||
bridge);
|
||||
var liveSession = new LiveSessionHost(
|
||||
runtime.Session,
|
||||
new LiveSessionHostBindings(
|
||||
new LiveSessionRoutingFactories(
|
||||
CreateEventRoute,
|
||||
commands.CreateRoute),
|
||||
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;
|
||||
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();
|
||||
credential.Dispose();
|
||||
runtimeRef?.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
internal GameRuntime Runtime { get; }
|
||||
internal DirectGameRuntimeCommandAdapter Commands { get; }
|
||||
internal string ActiveCharacterName { get; private set; } =
|
||||
string.Empty;
|
||||
internal bool IsPolicyComplete => _policy.IsComplete;
|
||||
|
||||
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);
|
||||
_ = Runtime.Clock.Advance(deltaSeconds);
|
||||
Runtime.Session.Tick();
|
||||
Runtime.ActionOwner.CombatAttack.Tick();
|
||||
_policy.Tick(Runtime, Commands);
|
||||
}
|
||||
|
||||
internal RuntimeTeardownAcknowledgement Stop() =>
|
||||
Commands.Session.Stop(Runtime.Generation);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
while (!_disposed)
|
||||
{
|
||||
switch (_disposeStage)
|
||||
{
|
||||
case 0:
|
||||
{
|
||||
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:
|
||||
_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 (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."));
|
||||
}
|
||||
|
||||
// ACE confirms CharacterLogOff before its account/session index
|
||||
// releases the retiring socket. Starting a replacement in that
|
||||
// short suffix makes ACE reject both sessions as Account In Use.
|
||||
// This is a one-time connection-lifecycle wait, never a steady
|
||||
// scheduler sleep; K2 moves it onto the absolute-deadline host.
|
||||
if (_reconnectQuiescence > TimeSpan.Zero)
|
||||
{
|
||||
Task.Delay(_reconnectQuiescence)
|
||||
.GetAwaiter()
|
||||
.GetResult();
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
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: () =>
|
||||
Stopwatch.GetTimestamp()
|
||||
/ (double)Stopwatch.Frequency),
|
||||
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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue