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