feat(headless): complete deterministic bot command parity

This commit is contained in:
Erik 2026-07-27 08:23:36 +02:00
parent 7e8acb74dd
commit 38e83640d9
37 changed files with 2805 additions and 295 deletions

View file

@ -3,12 +3,76 @@ 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<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;
@ -52,6 +116,7 @@ internal sealed class HeadlessSessionHost : IDisposable
private readonly IHeadlessBotPolicy _policy;
private readonly IDisposable _policySubscription;
private readonly LiveSessionHost _liveSession;
private readonly RuntimeLocalPlayerFrameController _localPlayerFrame;
private int _disposeStage;
private long _reconnectDeadline;
private bool _reconnectPending;
@ -114,7 +179,9 @@ internal sealed class HeadlessSessionHost : IDisposable
new LiveSessionHostBindings(
new LiveSessionRoutingFactories(
CreateEventRoute,
commands.CreateRoute),
session => new SessionCommandRoute(
gameplay.CreateRoute(session),
commands.CreateRoute(session))),
generation =>
runtime.ResetGeneration(generation, _resetHost),
new LiveSessionSelectionBindings(
@ -143,6 +210,13 @@ internal sealed class HeadlessSessionHost : IDisposable
Runtime = runtime;
Commands = commands;
_liveSession = liveSession;
_localPlayerFrame =
runtime.CreateLocalPlayerFrameController(
new HeadlessLocalPlayerFrameHost(
runtime,
liveSession),
new HeadlessMovementInputSource(
runtime.MovementOwner));
bridge.Bind(this);
hostLease = runtime.AcquireHostLease(
@ -194,7 +268,10 @@ internal sealed class HeadlessSessionHost : IDisposable
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);
}