1313 lines
56 KiB
C#
1313 lines
56 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.Chat;
|
|
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
|
|
{
|
|
private ILiveSessionCommandRouting? _gameplay;
|
|
private ILiveSessionCommandRouting? _commands;
|
|
private ILiveSessionCommandRouting? _chat;
|
|
private bool _activated;
|
|
|
|
internal SessionCommandRoute(
|
|
ILiveSessionCommandRouting gameplay,
|
|
ILiveSessionCommandRouting commands,
|
|
ILiveSessionCommandRouting chat)
|
|
{
|
|
_gameplay = gameplay
|
|
?? throw new ArgumentNullException(nameof(gameplay));
|
|
_commands = commands
|
|
?? throw new ArgumentNullException(nameof(commands));
|
|
_chat = chat
|
|
?? throw new ArgumentNullException(nameof(chat));
|
|
}
|
|
|
|
public void Activate()
|
|
{
|
|
if (_activated)
|
|
return;
|
|
if (_gameplay is null || _commands is null || _chat is null)
|
|
throw new ObjectDisposedException(nameof(SessionCommandRoute));
|
|
|
|
_gameplay.Activate();
|
|
try
|
|
{
|
|
_commands.Activate();
|
|
_chat.Activate();
|
|
_activated = true;
|
|
}
|
|
catch (Exception activationError)
|
|
{
|
|
try
|
|
{
|
|
Dispose();
|
|
}
|
|
catch (Exception disposalError)
|
|
{
|
|
throw new AggregateException(
|
|
"Headless command-route activation and rollback failed.",
|
|
activationError,
|
|
disposalError);
|
|
}
|
|
|
|
throw;
|
|
}
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
List<Exception>? failures = null;
|
|
TryDispose(ref _chat, ref failures);
|
|
TryDispose(ref _commands, ref failures);
|
|
TryDispose(ref _gameplay, ref failures);
|
|
if (failures is not null)
|
|
{
|
|
throw new AggregateException(
|
|
"Headless command routes did not detach cleanly.",
|
|
failures);
|
|
}
|
|
}
|
|
|
|
private static void TryDispose(
|
|
ref ILiveSessionCommandRouting? route,
|
|
ref List<Exception>? failures)
|
|
{
|
|
if (route is not { } current)
|
|
return;
|
|
|
|
try
|
|
{
|
|
current.Dispose();
|
|
route = null;
|
|
}
|
|
catch (Exception error)
|
|
{
|
|
(failures ??= []).Add(error);
|
|
}
|
|
}
|
|
}
|
|
|
|
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);
|
|
var chatCommandSurface = new LiveChatCommandSurface();
|
|
var loginCommands = new LoginCommandSequence(
|
|
descriptor.LoginCommands,
|
|
TimeSpan.FromMilliseconds(descriptor.LoginCommandDelayMs),
|
|
new RuntimeChatCommandFeedback(runtime.CommunicationOwner),
|
|
chatCommandSurface,
|
|
failure => statusWriter.LoginCommandFailed(
|
|
descriptor.Id,
|
|
failure.CommandIndex,
|
|
failure.Command,
|
|
failure.Error),
|
|
_timeProvider);
|
|
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),
|
|
chatCommandSurface.Attach(
|
|
new LiveChatCommandRoute(
|
|
CreateChatCommandBindings(
|
|
session,
|
|
runtime))))),
|
|
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),
|
|
loginCommands));
|
|
|
|
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));
|
|
_liveSession.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 LiveChatCommandBindings CreateChatCommandBindings(
|
|
AcDream.Core.Net.WorldSession session,
|
|
GameRuntime runtime) => new(
|
|
ExecuteClientCommand: command =>
|
|
ExecuteHeadlessClientCommand(session, runtime, command),
|
|
Communication: runtime.CommunicationOwner,
|
|
Chat: runtime.CommunicationOwner.Chat,
|
|
TurbineChat: runtime.CommunicationOwner.TurbineChat,
|
|
CharacterState: runtime.CharacterOwner,
|
|
PlayerGuid: () => runtime.PlayerIdentity.ServerGuid,
|
|
SendTalk: session.SendTalk,
|
|
SendTell: session.SendTell,
|
|
SendChannel: session.SendChannel,
|
|
SendTurbineChat: session.SendTurbineChatTo,
|
|
Log: message => _diagnostics.Message(
|
|
_descriptor.Id,
|
|
message,
|
|
runtime.Generation.Value));
|
|
|
|
/// <summary>
|
|
/// Presentation-free subset of retail client commands. Commands whose
|
|
/// semantics require a graphical confirmation/window or a host-specific
|
|
/// presentation service fail explicitly; the login sequence reports that
|
|
/// one line and continues. Wire-only and canonical-state commands take
|
|
/// the exact same WorldSession/Runtime paths as the graphical bindings.
|
|
/// </summary>
|
|
private static void ExecuteHeadlessClientCommand(
|
|
AcDream.Core.Net.WorldSession session,
|
|
GameRuntime runtime,
|
|
ExecuteClientCommandCmd command)
|
|
{
|
|
switch (command.Command)
|
|
{
|
|
case ClientCommandId.LifestoneRecall:
|
|
session.SendTeleportToLifestone();
|
|
return;
|
|
case ClientCommandId.MarketplaceRecall:
|
|
session.SendTeleportToMarketplace();
|
|
return;
|
|
case ClientCommandId.PkArenaRecall:
|
|
session.SendTeleportToPkArena();
|
|
return;
|
|
case ClientCommandId.PkLiteArenaRecall:
|
|
session.SendTeleportToPkLiteArena();
|
|
return;
|
|
case ClientCommandId.EnterPkLite:
|
|
session.SendEnterPkLite();
|
|
return;
|
|
case ClientCommandId.HouseRecall:
|
|
session.SendTeleportToHouse();
|
|
return;
|
|
case ClientCommandId.MansionRecall:
|
|
session.SendTeleportToMansion();
|
|
return;
|
|
case ClientCommandId.QueryAge:
|
|
session.SendQueryAge();
|
|
return;
|
|
case ClientCommandId.QueryBirth:
|
|
session.SendQueryBirth();
|
|
return;
|
|
case ClientCommandId.Emote
|
|
when !string.IsNullOrWhiteSpace(command.Arguments):
|
|
session.SendEmote(command.Arguments.Trim());
|
|
return;
|
|
case ClientCommandId.ClearChat:
|
|
runtime.CommunicationOwner.Chat.Clear();
|
|
return;
|
|
case ClientCommandId.ChatToggle:
|
|
// Retail DoChatToggle: "off" adds the global Speech
|
|
// squelch; "on" removes it.
|
|
session.SendModifyGlobalSquelch(
|
|
command.Arguments.Equals(
|
|
"off",
|
|
StringComparison.OrdinalIgnoreCase),
|
|
2u);
|
|
return;
|
|
case ClientCommandId.NoTellToggle:
|
|
// Retail DoNoTell: "on" adds the global Tell squelch;
|
|
// "off" removes it.
|
|
session.SendModifyGlobalSquelch(
|
|
command.Arguments.Equals(
|
|
"on",
|
|
StringComparison.OrdinalIgnoreCase),
|
|
3u);
|
|
return;
|
|
case ClientCommandId.IndexChannels:
|
|
session.SendIndexChannels();
|
|
return;
|
|
case ClientCommandId.ListChannel:
|
|
SendResolvedChannel(
|
|
command.Arguments,
|
|
session.SendListChannel);
|
|
return;
|
|
case ClientCommandId.OnChannel:
|
|
SendResolvedChannel(
|
|
command.Arguments,
|
|
session.SendOnChannel);
|
|
return;
|
|
case ClientCommandId.OffChannel:
|
|
SendResolvedChannel(
|
|
command.Arguments,
|
|
session.SendOffChannel);
|
|
return;
|
|
case ClientCommandId.AllegianceHometown:
|
|
session.SendRecallAllegianceHometown();
|
|
return;
|
|
case ClientCommandId.AllegianceInfo:
|
|
session.SendAllegianceInfoRequest(command.Arguments.Trim());
|
|
return;
|
|
case ClientCommandId.Permit:
|
|
ExecutePermit(session, command.Arguments);
|
|
return;
|
|
case ClientCommandId.HouseAvailableList
|
|
when RetailClientCommandCatalog.TryResolveHouseType(
|
|
command.Arguments,
|
|
out uint houseType):
|
|
session.SendListAvailableHouses(houseType);
|
|
return;
|
|
case ClientCommandId.JoinChannel
|
|
when RetailClientCommandCatalog.TryResolveJoinLeaveOption(
|
|
command.Arguments,
|
|
out uint joinOption):
|
|
_ = runtime.CharacterOwner.Options.TrySetOption(
|
|
joinOption,
|
|
true,
|
|
session.SendSetSingleCharacterOption);
|
|
return;
|
|
case ClientCommandId.LeaveChannel
|
|
when RetailClientCommandCatalog.TryResolveJoinLeaveOption(
|
|
command.Arguments,
|
|
out uint leaveOption):
|
|
_ = runtime.CharacterOwner.Options.TrySetOption(
|
|
leaveOption,
|
|
false,
|
|
session.SendSetSingleCharacterOption);
|
|
return;
|
|
default:
|
|
throw new NotSupportedException(
|
|
$"Client command '{command.Command}' is not available "
|
|
+ "in the headless host.");
|
|
}
|
|
|
|
static void SendResolvedChannel(
|
|
string arguments,
|
|
Action<uint> send)
|
|
{
|
|
if (!RetailChannelTagTable.TryResolve(
|
|
arguments.Trim(),
|
|
out uint channelId))
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Chat channel '{arguments.Trim()}' does not exist.");
|
|
}
|
|
send(channelId);
|
|
}
|
|
|
|
static void ExecutePermit(
|
|
AcDream.Core.Net.WorldSession activeSession,
|
|
string arguments)
|
|
{
|
|
// The catalog has already required add/remove plus a name.
|
|
// Match ClientCommandController's JoinArgsAsName behavior so
|
|
// multi-word character names remain one exact wire argument.
|
|
string[] parts = arguments.Split(
|
|
(char[]?)null,
|
|
StringSplitOptions.RemoveEmptyEntries);
|
|
string name = string.Join(' ', parts, 1, parts.Length - 1);
|
|
if (parts[0].Equals(
|
|
"add",
|
|
StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
activeSession.SendAddPlayerPermission(name);
|
|
}
|
|
else
|
|
{
|
|
activeSession.SendRemovePlayerPermission(name);
|
|
}
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|