feat(headless): complete portable single-session host

This commit is contained in:
Erik 2026-07-27 07:36:53 +02:00
parent fbebb91848
commit f8cb840fb1
30 changed files with 3571 additions and 32 deletions

View 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);
}
}