acdream/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs

1092 lines
47 KiB
C#

using AcDream.Headless.Configuration;
using AcDream.Headless.Credentials;
using AcDream.Headless.Diagnostics;
using AcDream.Headless.Plugins;
using AcDream.Headless.Policies;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Runtime;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Physics;
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;
/// <summary>
/// Campaign LA slice LA1: a SEPARATE per-session sink from
/// <see cref="_diagnostics"/> — a no-op instance when
/// <see cref="HeadlessSessionDescriptor.StatusFile"/> was not configured.
/// See <see cref="SessionStatusWriter"/>'s own doc for why this is not a
/// rework of the shared-stdout diagnostics writer.
/// </summary>
private readonly SessionStatusWriter _statusWriter;
/// <summary>
/// Campaign LA LA2 review fix: the actual result returned by the process
/// start attempt. A configured probe mode is only intent; terminal status
/// may claim <c>reason:"probe"</c> after this records
/// <see cref="RuntimeSessionStartStatus.ProbeComplete"/>. Any other
/// non-connected result maps to the same connection-error code returned by
/// <see cref="HeadlessProcessHost"/>.
/// </summary>
private RuntimeSessionStartStatus? _startOutcome;
/// <summary>Guards <see cref="Stop"/>'s <c>disconnected</c> status event
/// so a Stop() on a session that never actually reached Connected (e.g.
/// disposing a fresh, never-started host) does not report a spurious
/// disconnect.</summary>
private bool _hasConnected;
/// <summary>
/// Campaign OP slice OP7 (2026-08-11), D8: the parsed
/// <c>characterOptions</c> block — empty when the config omitted it.
/// Parsed once at construction; <see cref="HeadlessConfigurationLoader"/>
/// already rejected every unmodelled or out-of-tier name before this
/// host was ever built, so the parse here cannot fail.
/// </summary>
private readonly Dictionary<CharacterOptionId, bool> _declaredCharacterOptions;
/// <summary>
/// Reassigned on every reconnect exactly like <see cref="_worldProjection"/>
/// — a fresh instance per <see cref="CreateEventRoute"/> call gives the
/// seeder's own login-complete latch a clean per-session start without a
/// separate reset method.
/// </summary>
private HeadlessCharacterOptionsSeeder? _optionsSeeder;
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 HeadlessPluginSession _pluginSession;
private readonly LiveSessionHost _liveSession;
private readonly RuntimeLocalPlayerFrameController _localPlayerFrame;
private readonly HeadlessProcessContentOwner.HeadlessProcessContentLease?
_contentLease;
/// <summary>
/// B5(a) review fix: test-only seam (mirrors <c>policyOverride</c>'s own
/// pattern) letting a focused test substitute a deterministic fake
/// placement sink for the production <see cref="HeadlessRuntimePlacementProjectionSink"/>,
/// so a test can drive <see cref="Tick"/> itself — the real
/// <c>_eventRoute?.RetryPending()</c> call this fix covers — instead of
/// hand-constructing a <see cref="HeadlessSessionEventRoute"/> outside
/// this host. <c>null</c> (every production caller) keeps today's exact
/// behavior.
/// </summary>
private readonly IRuntimePlacementProjectionSink? _placementSinkOverride;
/// <summary>C3c: one per-host first-entry drive controller (lazy — its
/// residence-begin subscription binds once against the persistent
/// Runtime lifetime) plus the active world projection it pumps
/// through.</summary>
private RuntimeFirstEntryDriveController? _firstEntryDrive;
/// <summary>C4 route 2 (2026-08-03): see the ctor comment in
/// <see cref="CreateEventRoute"/> — cached across reconnects exactly
/// like <see cref="_firstEntryDrive"/>.</summary>
private RuntimeAcceptedPositionDriveController? _acceptedPositionDrive;
private AcDream.Core.Net.WorldSession? _currentSession;
private HeadlessSessionWorldProjection? _worldProjection;
/// <summary>
/// A1/A3 review fix (2026-08-05): retained so <see cref="Tick"/> can
/// pump <see cref="RuntimeLiveEntitySessionController.PumpPortalCompletion"/>
/// alongside <see cref="_worldProjection"/>'s own
/// <c>PumpFirstEntry</c> — a parked portal placement must retry on the
/// host's own per-tick cadence rather than the completion sequence
/// running unconditionally the instant it is first attempted. Reassigned
/// on every reconnect exactly like <see cref="_worldProjection"/>.
/// </summary>
private RuntimeLiveEntitySessionController? _entities;
/// <summary>C4 route 4b-1 (N3): the exact route <see cref="CreateEventRoute"/>
/// last constructed, so <see cref="Tick"/> can republish the canonical
/// placement FIFO every tick — mirrors the graphical host's per-frame
/// retry lease (<c>GraphicalSessionEventRoute.Attach</c>). Reassigned on
/// every reconnect exactly like <see cref="_worldProjection"/>; the prior
/// route's own disposal (via <c>LiveSessionHost</c>'s route replacement)
/// is independent of this field.</summary>
private HeadlessSessionEventRoute? _eventRoute;
/// <summary>
/// Campaign FA slice FA6: the single outstanding <c>0x0274
/// Character.ConfirmationRequest</c>, or <see langword="null"/> when
/// none is pending. Graphical hosts route this to
/// <c>GameplayConfirmationController</c>
/// (<c>LiveSessionRuntimeFactory.cs:315</c>); a headless bot has no
/// panel, so this single-slot latch (matching retail's own "one open
/// dialog at a time" shape) plus <see cref="RespondToConfirmation"/> is
/// the bot-visible substitute a policy can poll and answer — the
/// allegiance swear flow requires it: retail always confirms an
/// incoming swear to the PATRON before <c>0x0020</c>/<c>0x01C8</c>
/// ship to either party (docs/research/2026-08-11-fa-allegiance-wire.md
/// §3.3), and there is no auto-accept character option for it (unlike
/// fellowship's <c>FellowshipAutoAcceptRequests</c>, which ACE honors
/// SERVER-SIDE without ever sending the client a confirmation at all).
/// Reassigned on every reconnect exactly like <see cref="_worldProjection"/>
/// — a stale pre-reconnect context id would be meaningless post-reconnect.
/// </summary>
private GameEvents.CharacterConfirmationRequest? _pendingConfirmation;
private int _disposeStage;
private long _reconnectDeadline;
private bool _reconnectPending;
private ulong _stoppedGeneration;
private string _accountName = string.Empty;
private Exception? _fault;
private bool _faulted;
private bool _disposed;
internal HeadlessSessionHost(
HeadlessSessionDescriptor descriptor,
HeadlessCredentialSecret credential,
HeadlessDiagnosticWriter diagnostics,
ILiveSessionOperations? sessionOperations = null,
TimeProvider? timeProvider = null,
TimeSpan? reconnectQuiescence = null,
HeadlessProcessContentOwner.HeadlessProcessContentLease?
contentLease = null,
IHeadlessBotPolicy? policyOverride = null,
IRuntimePlacementProjectionSink? placementSinkOverride = null,
FellowshipAllegianceGateCoordinator? gateCoordinator = null,
IEnumerable<string>? pluginRoots = null)
{
_descriptor = descriptor
?? throw new ArgumentNullException(nameof(descriptor));
_credential = credential
?? throw new ArgumentNullException(nameof(credential));
_diagnostics = diagnostics
?? throw new ArgumentNullException(nameof(diagnostics));
_declaredCharacterOptions = ParseDeclaredCharacterOptions(descriptor);
_placementSinkOverride = placementSinkOverride;
_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;
HeadlessPluginSession? pluginSession = 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);
// Campaign LA slice LA1: no-op instance when
// descriptor.StatusFile is unset — every call site below stays
// unconditional.
var statusWriter = new SessionStatusWriter(descriptor.StatusFile);
pluginSession = HeadlessPluginSession.Create(
runtime,
diagnostics,
statusWriter,
descriptor.Id,
pluginRoots ?? [],
descriptor.Plugins);
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;
// FA6: only the Recruit half publishes its own
// discovered name — the Leader's proximity
// stage reads this to disambiguate the actual
// counterpart bot from any other player entity
// a shared ACE dev instance happens to have
// online (live-run finding, see
// RuntimeFriendlyTargetQuery.FindPlayerByName's
// doc). Gating on role keeps two sessions
// writing the SAME field from ever racing —
// only one role ever writes it.
if (descriptor.Policy?.Role
== HeadlessBotPolicyRole.Recruit
&& gateCoordinator is not null)
{
gateCoordinator.RecruitCharacterName = name;
}
},
() => { },
() => { },
_ => { },
() => { }),
(host, port, user) =>
diagnostics.Message(
descriptor.Id,
$"connecting:{host}:{port}:{user}",
runtime.Generation.Value),
() =>
{
diagnostics.Message(
descriptor.Id,
"connected",
runtime.Generation.Value);
statusWriter.Connected(descriptor.Id);
_hasConnected = true;
},
roster => statusWriter.CharacterList(descriptor.Id, roster),
selection => statusWriter.EnteredWorld(
descriptor.Id,
selection.CharacterId,
selection.CharacterName)));
Runtime = runtime;
Commands = commands;
_liveSession = liveSession;
_statusWriter = statusWriter;
_localPlayerFrame =
runtime.CreateLocalPlayerFrameController(
new HeadlessLocalPlayerFrameHost(
runtime,
liveSession),
new HeadlessMovementInputSource(
runtime.MovementOwner));
_contentLease = contentLease;
bridge.Bind(this);
hostLease = runtime.AcquireHostLease(
$"headless:{descriptor.Id}");
// Campaign LA slice LA2: a probe session's descriptor carries no
// `policy` at all (the loader rejects the opposite pairing) — a
// probe never reaches TrySelectCharacter/EnterWorld, so there is
// no policy id to switch on. ProbeHeadlessBotPolicy reports
// IsComplete unconditionally so HeadlessProcessScheduler treats
// this session as already finished the instant it is
// constructed, letting the scheduler's Run() loop return
// immediately for a probe-only process instead of waiting for
// SIGINT.
policy = policyOverride
?? (descriptor.Mode == HeadlessSessionMode.Probe
? new ProbeHeadlessBotPolicy()
: HeadlessBotPolicyFactory.Create(
descriptor.Policy!,
runtime,
() => _pendingConfirmation,
RespondToConfirmation,
gateCoordinator));
policySubscription = runtime.Subscribe(policy);
diagnostics.Lifecycle(
descriptor.Id,
"constructed",
runtime);
_hostLease = hostLease;
_policy = policy;
_policySubscription = policySubscription;
_pluginSession = pluginSession;
}
catch
{
pluginSession?.Dispose();
policySubscription?.Dispose();
policy?.Dispose();
hostLease?.Dispose();
contentLease?.Dispose();
credential.Dispose();
runtimeRef?.Dispose();
throw;
}
}
internal GameRuntime Runtime { get; }
internal DirectGameRuntimeCommandAdapter Commands { get; }
/// <summary>
/// OP7 test seam (mirrors <see cref="Commands"/>'s own visibility): the
/// current route's declared-<c>characterOptions</c> diff-and-send engine,
/// or <c>null</c> before the first <c>CreateEventRoute</c> call. Lets a
/// focused test drive one half of the seeder's two-precondition latch
/// directly (e.g. simulate "LoginComplete already sent") without
/// reconstructing the first-entry-drive/portal-completion machinery that
/// production code uses to reach the same state.
/// </summary>
internal HeadlessCharacterOptionsSeeder? OptionsSeeder => _optionsSeeder;
internal HeadlessPluginSession Plugins => _pluginSession;
internal string SessionId => _descriptor.Id;
internal string ActiveCharacterName { get; private set; } =
string.Empty;
internal bool IsPolicyComplete =>
_faulted || _policy.IsComplete;
internal bool IsFaulted => _faulted;
internal Exception? Fault => _fault;
internal bool IsReconnectPending => _reconnectPending;
internal HeadlessProcessContentOwner.HeadlessProcessContentLease?
Content => _contentLease;
internal long ReconnectDeadline => _reconnectPending
? _reconnectDeadline
: throw new InvalidOperationException(
"The headless session has no pending reconnect.");
/// <summary>Campaign FA slice FA6: see <see cref="_pendingConfirmation"/>.</summary>
internal GameEvents.CharacterConfirmationRequest? PendingConfirmation =>
_pendingConfirmation;
/// <summary>
/// Campaign FA slice FA6: sends <c>0x0275 ConfirmationResponse</c> for
/// the current <see cref="PendingConfirmation"/> and clears the latch.
/// Throws if none is pending — mirrors <c>Require</c>'s fail-loud
/// convention elsewhere in this file rather than silently no-op'ing.
/// A missing <see cref="_currentSession"/> (never connected, or
/// mid-reconnect) is a silent no-op — matches every other
/// <c>_currentSession?.Send*</c> site in this class.
/// </summary>
internal void RespondToConfirmation(bool accepted)
{
if (_pendingConfirmation is not { } request)
{
throw new InvalidOperationException(
"No confirmation request is pending.");
}
_currentSession?.SendConfirmationResponse(
request.Type,
request.ContextId,
accepted);
_pendingConfirmation = null;
}
internal RuntimeSessionStartResult Start()
{
// Campaign LA slice LA1: "started" = session host start — the
// earliest point this session actually attempts to connect.
_statusWriter.Started(_descriptor.Id);
_pluginSession.Start();
RuntimeSessionStartResult result =
Commands.Session.Start(Runtime.Generation);
_startOutcome = result.Status;
return result;
}
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();
// C3c: pump pending first-entry sequences after the network drain —
// collision-generation progress and freshly accepted Creates both
// surface here, mirroring the graphical per-frame retry phase.
_worldProjection?.PumpFirstEntry();
// A1/A3 review fix (2026-08-05): retry a parked portal completion
// (see RuntimeLiveEntitySessionController.PumpPortalCompletion) on
// the SAME per-tick cadence, after first-entry so a DeferredCell
// wake first-entry's own pump just resolved is picked up the same
// tick.
_entities?.PumpPortalCompletion();
// C4 route 4b-1 (N3): republish the canonical placement FIFO LAST,
// same order as the graphical host's retry-lease callback (drives
// first, retry last) — a declined Place left at the FIFO head by
// RuntimePlacementProjectionSubscription is otherwise never
// revisited, because Attach's retryPendingOnSubscribe only fires
// once, at subscribe time.
_eventRoute?.RetryPending();
_localPlayerFrame.RunPostNetworkCommandPhase();
Runtime.ActionOwner.CombatAttack.Tick();
_policy.Tick(Runtime, Commands);
}
internal RuntimeTeardownAcknowledgement Stop(string reason = "stopped")
{
ArgumentException.ThrowIfNullOrWhiteSpace(reason);
RuntimeTeardownAcknowledgement result =
Commands.Session.Stop(Runtime.Generation);
// R9 review fix (2026-08-03): _currentSession is cached across
// reconnects (see CreateEventRoute's comment) so the accepted-
// position drive controller's outbound-ack accessor always reads the
// CURRENT session, never one captured at first construction. Without
// clearing it here, that accessor would keep returning a stopped
// (possibly disposed) WorldSession in the window between this Stop
// and the next CreateEventRoute call.
_currentSession = null;
// Campaign LA slice LA1: only report a disconnect for a session that
// actually reached Connected — a Stop() on a never-started or
// never-connected host (e.g. immediate Dispose()) is not a real
// disconnect.
if (_hasConnected)
{
_hasConnected = false;
_statusWriter.Disconnected(_descriptor.Id, reason);
}
return result;
}
internal void Quarantine(Exception error)
{
ArgumentNullException.ThrowIfNull(error);
if (_faulted)
return;
_faulted = true;
_fault = error;
_reconnectPending = false;
_reconnectDeadline = 0L;
_diagnostics.Failure(
_descriptor.Id,
"quarantined",
error);
try
{
// A policy may have faulted from an event callback. Detach it
// before Runtime publishes teardown deltas so the quarantined
// observer cannot poison the canonical stop transaction.
_policySubscription.Dispose();
RuntimeTeardownAcknowledgement stopped = Stop();
if (!stopped.IsComplete)
{
_fault = new AggregateException(
error,
stopped.Error
?? new InvalidOperationException(
$"Headless session '{_descriptor.Id}' did not quiesce after a fault."));
}
}
catch (Exception teardownError)
{
_fault = new AggregateException(error, teardownError);
}
}
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:
_pluginSession.Dispose();
_disposeStage++;
break;
case 5:
_hostLease.Dispose();
_disposeStage++;
break;
case 6:
_credential.Dispose();
_disposeStage++;
break;
case 7:
Runtime.Dispose();
_disposeStage++;
break;
case 8:
_contentLease?.Dispose();
_disposeStage++;
break;
case 9:
_diagnostics.Message(
_descriptor.Id,
"disposed",
_stoppedGeneration);
// Campaign LA slice LA1: "exited" = terminal — the sole
// point every disposal path (graceful and post-
// quarantine) converges on. LA2: only an actual
// ProbeComplete start outcome reports reason "probe";
// configured probe intent cannot turn a failed start into
// a successful terminal event.
(int exitCode, string exitReason) =
ResolveTerminalStatus();
_statusWriter.Exited(
_descriptor.Id,
exitCode,
exitReason);
_disposeStage++;
_disposed = true;
break;
default:
throw new InvalidOperationException(
"Unknown headless session teardown stage.");
}
}
}
/// <summary>
/// Produces the same terminal classification the owning process host uses.
/// Descriptor mode never participates: only an observed ProbeComplete may
/// report a successful probe. The surrounding disposal stage and LA1's
/// terminal/idempotent <see cref="SessionStatusWriter"/> make this event
/// exact-once even when disposal is retried.
/// </summary>
private (int Code, string Reason) ResolveTerminalStatus()
{
if (_faulted)
{
return (
(int)HeadlessExitCode.RuntimeError,
"runtime-fault");
}
return _startOutcome switch
{
RuntimeSessionStartStatus.ProbeComplete =>
((int)HeadlessExitCode.Success, "probe"),
null or RuntimeSessionStartStatus.Connected =>
((int)HeadlessExitCode.Success, "graceful"),
_ =>
((int)HeadlessExitCode.ConnectionError, "connection-error"),
};
}
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)
{
// Campaign LA LA1 review fix F3: route reconnect teardown
// through the same status-aware Stop boundary as every other
// host stop. The retiring connection therefore publishes a
// truthful disconnected(reason: "reconnect") edge before the
// fresh LiveSessionHost reports its second connected edge.
RuntimeTeardownAcknowledgement stopped = Stop("reconnect");
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),
Probe: _descriptor.Mode == HeadlessSessionMode.Probe);
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 ILiveSessionEventRouting CreateEventRoute(
AcDream.Core.Net.WorldSession session)
{
// C4 route 2 (2026-08-03): reconnects construct a FRESH WorldSession
// each call, but the accepted-Position drive controller below is
// cached across reconnects (`??=`) exactly like _firstEntryDrive —
// its ack-firing accessor must therefore read the CURRENT session
// through this field, never one captured at first construction.
_currentSession = session;
// FA6: a stale pre-reconnect confirmation context id is meaningless
// against the fresh WorldSession above — drop it rather than let a
// policy answer a confirmation that no longer has a live listener.
_pendingConfirmation = null;
// OP7: a fresh seeder per route — see the field's own doc comment
// for why this (rather than a reset method) is the right per-
// reconnect lifetime.
_optionsSeeder = new HeadlessCharacterOptionsSeeder(
_declaredCharacterOptions,
Runtime,
Commands.Character);
// R9 review note (2026-08-03): a content-less host (_contentLease is
// null — a validated-legal headless configuration, see
// RuntimeLiveEntitySessionController.OnSpawned's own R3 comment)
// never constructs _acceptedPositionDrive OR worldProjection below,
// so a ForcePosition on such a host resolves NotApplicable with no
// ProjectPosition fallback either. This is NOT a behaviour change:
// a content-less host never registers a first-entry residence, so
// Runtime.MovementOwner.Controller is always null there too, and
// LocalPlayerOutboundController.SendImmediatePosition's own
// controller-null guard already made the PRE-route-2 unconditional
// ack call a no-op in this exact configuration. There is no live
// controller for either the old or the new path to place or
// acknowledge.
IRuntimeDirectWorldProjection? worldProjection = null;
if (_contentLease is { } content)
{
// C3c: one drive controller per host — the residence-begin
// notification binds once against the persistent Runtime
// lifetime; reconnects reuse it (its tracked entries are cleared
// with each retiring route).
_firstEntryDrive ??= new RuntimeFirstEntryDriveController(
Runtime.EntityObjects,
Runtime.Clock,
content.PreparedCollision,
() => PlayerMovementConstructionOptions.From(
Runtime.CharacterOwner.MovementSkills.Snapshot),
// A headless host registers no shadow payloads — the local
// player is provably shapeless in the shadow registry, with
// the same default approach cylinder the deleted
// hand-resolve used.
static _ => new RuntimeLocalPlayerPhysicsActivationPreparation(
Radius: 0.48f,
Height: 1.835f,
RuntimeLocalPlayerShadowDisposition.ProvenShapeless));
// D-T8 (temporary probe): labels every subsequent
// PhysicsDiagnostics.LogLocalTeleportArrival line from THIS
// process as headless — Runtime itself has no host-kind concept
// (Slice K keeps it presentation-agnostic), so this is a
// diagnostics-only label set once at composition time, not a
// Runtime dependency.
PhysicsDiagnostics.LocalTeleportHostKind = "headless";
// C4 route 2: one drive controller per host, mirroring
// _firstEntryDrive exactly — same persistent Runtime lifetime,
// collision source, and clock.
_acceptedPositionDrive ??= new RuntimeAcceptedPositionDriveController(
Runtime.EntityObjects,
Runtime.Clock,
content.PreparedCollision,
new LocalPlayerOutboundController((_, _, _, _, _, _) => { }),
() => Runtime.Generation,
() => Runtime.PlayerIdentity.ServerGuid,
() => Runtime.MovementOwner.Controller,
() => Runtime.CharacterOwner.UsePositionFromServer,
() => _currentSession,
// C4 route 3: the portal arm's PlayerTeleported port needs
// the J5.4 autorun latch owner, one level above the raw
// controller.
() => Runtime.MovementOwner,
// A2/D-T2.4 review fix (2026-08-05): same wiring as the
// graphical composition (SessionPlayerComposition.cs) — the
// SAME idempotent query TryCompletePortal/PrepareDestination
// themselves use.
isPortalAuthorityCurrent: portal => Runtime.TransitOwner
.CanPlacePortalDestination(
portal.RevealGeneration,
portal.TeleportSequence,
portal.Projection.DestinationCell));
var projection = new HeadlessSessionWorldProjection(
Runtime,
content,
_firstEntryDrive,
_acceptedPositionDrive,
// Consolidated-review round (2026-08-10), NIT (b): the
// SAME session-labelled diagnostics stream every other
// producer in this class writes into.
onNonQuiescentStall: message => _diagnostics.Message(
_descriptor.Id,
message,
Runtime.Generation.Value));
_worldProjection = projection;
worldProjection = projection;
}
var entities = new RuntimeLiveEntitySessionController(
Runtime,
session,
message => _diagnostics.Message(
_descriptor.Id,
message,
Runtime.Generation.Value),
worldProjection,
_acceptedPositionDrive,
// OP7: the first two of three production LoginComplete send
// sites — see RuntimeLiveEntitySessionController's own doc.
onLoginCompleteSent: () => _optionsSeeder?.NoteLoginCompleteSent());
_entities = entities;
var route = 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),
Vendor: Runtime.InventoryOwner.Vendor),
new LiveCharacterSessionBindings(
Runtime.ActionOwner.Combat,
Runtime.CharacterOwner,
ResolveSkillFormulaBonus: null,
OnSkillsUpdated: null,
OnConfirmationRequest: request =>
{
// Kept as a permanent, low-volume diagnostic — docs/
// ISSUES.md #384's open question is precisely "does
// ACE ever send 0x0274 for the allegiance swear at
// all", and this line is the only place that would
// show a positive answer once someone re-enables
// AllegianceGateEnabled and reruns the gate.
Console.WriteLine(
$"[fa6-diag] OnConfirmationRequest received type="
+ $"{request.Type} context={request.ContextId} "
+ $"text='{request.Message}'");
_pendingConfirmation = request;
},
OnConfirmationDone: null,
ClientTime: () =>
Runtime.Clock.SimulationTimeSeconds,
// Campaign P Slice P1 (2026-07-30): headless bots re-apply
// burden/stamina/skills at controller construction only
// (HeadlessSessionWorldProjection.CreateController), same as
// the pre-P1 OnSkillsUpdated: null pattern — no live
// controller to reactively re-apply to mid-session here.
OnMovementStatsUpdated: null,
// OP7: fires after RuntimeCharacterOptionsState.Replace has
// already committed a fresh PlayerDescription's option words
// — the seeder's other precondition alongside
// NoteLoginCompleteSent (see its own type doc).
OnCharacterOptionsChanged: (_, _) =>
_optionsSeeder?.NoteOptionsSeeded()),
new LiveSocialSessionBindings(
Runtime.CommunicationOwner.Chat,
Runtime.CommunicationOwner.TurbineChat,
Runtime.CommunicationOwner.Friends,
Runtime.CommunicationOwner.Squelch,
(text, type) => Runtime.CommunicationOwner.AddText(text, type),
Fellowship: Runtime.FellowshipOwner,
Allegiance: Runtime.AllegianceOwner));
var eventRoute = new HeadlessSessionEventRoute(
route,
Runtime,
_placementSinkOverride
?? new HeadlessRuntimePlacementProjectionSink(Runtime),
_firstEntryDrive,
_ =>
{
session.SendGameAction(GameActionLoginComplete.Build());
// OP7: the THIRD production LoginComplete send site (direct,
// non-portal first-entry completion) — see
// HeadlessCharacterOptionsSeeder's type doc and
// RuntimeLiveEntitySessionController's onLoginCompleteSent
// doc for the other two.
_optionsSeeder?.NoteLoginCompleteSent();
},
_acceptedPositionDrive);
_eventRoute = eventRoute;
return eventRoute;
}
/// <summary>
/// OP7, D8: parses <see cref="HeadlessSessionDescriptor.CharacterOptions"/>
/// into the typed id space the seeder and <see cref="CharacterOptionTable"/>
/// share. <see cref="HeadlessConfigurationLoader"/> already validated
/// every key against the exact bot-declarable name set before this host
/// was constructed, so <c>Enum.Parse</c> here cannot fail.
/// </summary>
private static Dictionary<CharacterOptionId, bool> ParseDeclaredCharacterOptions(
HeadlessSessionDescriptor descriptor)
{
var declared = new Dictionary<CharacterOptionId, bool>();
if (descriptor.CharacterOptions is not { } options)
return declared;
foreach (KeyValuePair<string, bool> pair in options)
{
declared[Enum.Parse<CharacterOptionId>(pair.Key, ignoreCase: false)] =
pair.Value;
}
return declared;
}
/// <summary>Campaign LA slice LA2: <see langword="null"/> for a probe
/// session (the loader guarantees <c>Character</c> is omitted whenever
/// <c>Mode</c> is <see cref="HeadlessSessionMode.Probe"/>) — a probe
/// never reaches <c>TrySelectCharacter</c>, so "no selector configured"
/// is the correct, harmless mapping.</summary>
private static LiveSessionCharacterSelector? MapCharacterSelector(
HeadlessCharacterSelector? selector) =>
selector is null
? null
: 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,
LiveSessionStartStatus.ProbeComplete =>
RuntimeSessionStartStatus.ProbeComplete,
_ => 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);
}
}