using AcDream.Headless.Configuration; using AcDream.Headless.Credentials; using AcDream.Headless.Diagnostics; using AcDream.Headless.Platform; using AcDream.Headless.Policies; using AcDream.Runtime; using AcDream.Runtime.Session; namespace AcDream.Headless.Hosting; internal sealed class HeadlessProcessHost : IDisposable { private readonly HeadlessSessionHost[] _sessions; private readonly HeadlessProcessScheduler _scheduler; private readonly HeadlessDiagnosticWriter _diagnostics; private readonly HeadlessProcessContentOwner? _content; private readonly HeadlessProcessResourceSampler _resources; private int _disposeIndex; private bool _disposed; internal HeadlessProcessHost( HeadlessConfiguration configuration, HeadlessPathSet paths, TextReader standardInput, TextWriter diagnostics, ILiveSessionOperations? sessionOperations = null, TimeProvider? timeProvider = null, IHeadlessProcessContentFactory? contentFactory = null, HeadlessDirectCredentials? directCredentials = null) { ArgumentNullException.ThrowIfNull(configuration); ArgumentNullException.ThrowIfNull(paths); ArgumentNullException.ThrowIfNull(standardInput); ArgumentNullException.ThrowIfNull(diagnostics); if (configuration.Sessions.Count == 0) { throw new HeadlessConfigurationException( "Headless run mode requires at least one session."); } if (directCredentials is not null && configuration.Sessions.Count != 1) { throw new HeadlessConfigurationException( "Direct credentials require exactly one configured session."); } // Consolidated-review round (2026-08-10), NIT (a): construct the // diagnostics writer BEFORE the audit call so its single-session // "probes enabled" line routes through the same structured stream // every other headless diagnostic uses, instead of a bare // Console.WriteLine that bypassed it. _diagnostics = new HeadlessDiagnosticWriter(diagnostics); HeadlessStaticStateAudit.ValidateProcessIsolation( configuration.Sessions.Count, _diagnostics); var credentials = new HeadlessCredentialResolver( standardInput, paths.ConfigDirectory); var sessions = new List( configuration.Sessions.Count); string[] pluginRoots = [ Path.Combine(AppContext.BaseDirectory, "plugins"), paths.PluginsDirectory, ]; HeadlessProcessContentOwner? content = null; HeadlessProcessResourceSampler? resources = null; // FA6: constructed unconditionally — cheap, and every non-gate // session simply never reads or writes it (see the coordinator's // own class doc). var gateCoordinator = new FellowshipAllegianceGateCoordinator(); try { if (configuration.Process?.Content is { } contentDescriptor) { content = new HeadlessProcessContentOwner( contentDescriptor, message => _diagnostics.Message( "process-content", message), contentFactory); } foreach (HeadlessSessionDescriptor? candidate in configuration.Sessions) { HeadlessSessionDescriptor descriptor = candidate ?? throw new HeadlessConfigurationException( "Headless run mode cannot contain a null session."); if (directCredentials is not null) { descriptor = WithAccount( descriptor, directCredentials.User); } HeadlessCredentialSecret secret = directCredentials is null ? credentials.Resolve( descriptor.Id, descriptor.Credential) : new HeadlessCredentialSecret( "command-line", directCredentials.Password); HeadlessProcessContentOwner.HeadlessProcessContentLease? contentLease = content?.AcquireLease(descriptor.Id); try { sessions.Add(new HeadlessSessionHost( descriptor, secret, _diagnostics, sessionOperations, timeProvider, contentLease: contentLease, gateCoordinator: gateCoordinator, pluginRoots: pluginRoots)); } catch { contentLease?.Dispose(); secret.Dispose(); throw; } } _sessions = sessions.ToArray(); resources = new HeadlessProcessResourceSampler(timeProvider); _scheduler = new HeadlessProcessScheduler( _sessions, timeProvider, observation: snapshot => CaptureResources( "periodic", resources, snapshot, content)); _resources = resources; _content = content; _disposeIndex = _sessions.Length - 1; } catch { resources?.Dispose(); for (int index = sessions.Count - 1; index >= 0; index--) sessions[index].Dispose(); content?.Dispose(); throw; } } internal HeadlessSessionHost Session => _sessions.Length == 1 ? _sessions[0] : throw new InvalidOperationException( "The process owns more than one headless session."); internal IReadOnlyList Sessions => _sessions; internal HeadlessSchedulerSnapshot Scheduler => _scheduler.CaptureSnapshot(); internal HeadlessProcessContentSnapshot? Content => _content?.CaptureSnapshot(); // MF-1 (Campaign OP OP7 review fix, 2026-08-11): `with` copies every // record property that this method doesn't explicitly override, so a // future HeadlessSessionDescriptor property can never be silently // dropped here the way CharacterOptions previously was by the // hand-rolled six-of-seven-property object initializer. private static HeadlessSessionDescriptor WithAccount( HeadlessSessionDescriptor source, string account) => source with { Account = account }; internal Task RunAsync( CancellationToken cancellationToken) { ObjectDisposedException.ThrowIf(_disposed, this); // #368: Runtime's gameplay owners require ONE update thread for a // session's whole lifetime — collision generations bind to the // first mutating thread and refuse migration. The graphical host // satisfies that with its game-loop thread; this dedicated thread // is the headless equivalent. Start (the live connect // transaction), every scheduler turn, and the post-loop captures // all execute here. Only disposal stays on the lifecycle thread, // which the Runtime teardown path explicitly supports (see // ResetSessionPhysics's own doc comment). var completion = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); var thread = new Thread(() => { try { completion.SetResult( RunOnUpdateThread(cancellationToken)); } catch (Exception error) { completion.SetException(error); } }) { IsBackground = true, Name = "acdream-headless-update", }; thread.Start(); return completion.Task; } private HeadlessExitCode RunOnUpdateThread( CancellationToken cancellationToken) { foreach (HeadlessSessionHost session in _sessions) { RuntimeSessionStartResult started = session.Start(); // Campaign LA slice LA2: ProbeComplete is a SUCCESS variant, not // a connection failure — the session already connected, reported // its roster, and gracefully disconnected before EnterWorld (see // LiveSessionController's probe short-circuit). Continue to the // next configured session instead of returning ConnectionError, // so a probe session sharing a process with play sessions never // tears the others down. ProbeHeadlessBotPolicy already reports // IsComplete, so the scheduler below skips this session entirely. if (started.Status == RuntimeSessionStartStatus.ProbeComplete) { _diagnostics.Lifecycle( session.SessionId, "probed", session.Runtime); continue; } if (started.Status != RuntimeSessionStartStatus.Connected) { if (started.Error is { } error) { _diagnostics.Failure( session.SessionId, "start", error); } return HeadlessExitCode.ConnectionError; } _diagnostics.Lifecycle( session.SessionId, "running", session.Runtime); } _scheduler.RebaseDeadlinesAfterSessionStart(); CaptureResources( "running-start", _resources, _scheduler.CaptureSnapshot(), _content); try { _scheduler.Run(cancellationToken); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { } catch (Exception error) { _diagnostics.Failure("process", "tick", error); return HeadlessExitCode.RuntimeError; } CaptureResources( "running-stop", _resources, _scheduler.CaptureSnapshot(), _content); return _scheduler.CaptureSnapshot().FaultedSessionCount > 0 ? HeadlessExitCode.RuntimeError : HeadlessExitCode.Success; } public void Dispose() { if (_disposed) return; while (_disposeIndex >= 0) { _sessions[_disposeIndex].Dispose(); _disposeIndex--; } _content?.Dispose(); CaptureResources( "disposed", _resources, _scheduler.CaptureSnapshot(), _content); _resources.Dispose(); _disposed = true; } private void CaptureResources( string state, HeadlessProcessResourceSampler resources, HeadlessSchedulerSnapshot scheduler, HeadlessProcessContentOwner? content) { HeadlessProcessResourceSnapshot snapshot = resources.Capture( state, _sessions, scheduler, content?.CaptureSnapshot()); _diagnostics.Resources(state, snapshot); } }