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

@ -1,8 +1,10 @@
using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Net;
using AcDream.Core.Spells;
using AcDream.Runtime;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Session;
namespace AcDream.Headless.Hosting;
@ -17,64 +19,251 @@ internal sealed class HeadlessGameplayOperations
IRuntimeCombatModeOperations,
IRuntimeSpellCastOperations
{
private sealed class SessionRoute(
HeadlessGameplayOperations owner,
WorldSession session)
: ILiveSessionCommandRouting
{
private bool _active;
public void Activate()
{
if (_active)
return;
owner.Activate(session, this);
_active = true;
}
public void Dispose()
{
owner.Deactivate(this);
_active = false;
}
}
private readonly object _gate = new();
private GameRuntime? _runtime;
private WorldSession? _session;
private SessionRoute? _route;
internal void Bind(GameRuntime runtime) =>
_runtime = runtime
?? throw new ArgumentNullException(nameof(runtime));
public bool CanStartAttack() => false;
internal ILiveSessionCommandRouting CreateRoute(
WorldSession session)
{
ArgumentNullException.ThrowIfNull(session);
return new SessionRoute(this, session);
}
public bool CanStartAttack()
{
GameRuntime runtime = RequireRuntime();
if (!IsInWorld
|| !CombatInputPlanner.SupportsTargetedAttack(
runtime.ActionOwner.Combat.CurrentMode))
{
return false;
}
return GetSelectedOrClosestTarget(runtime) is not null;
}
public void PrepareAttackRequest()
{
_ = RequireRuntime().MovementOwner.PrepareForAttackRequest();
}
public bool SendAttack(AttackHeight height, float power)
{
GameRuntime runtime = RequireRuntime();
uint? target = GetSelectedOrClosestTarget(runtime);
if (target is null || !TryGetSession(out WorldSession? session))
return false;
power = Math.Clamp(power, 0f, 1f);
if (runtime.ActionOwner.Combat.CurrentMode == CombatMode.Missile)
session!.SendMissileAttack(target.Value, height, power);
else
session!.SendMeleeAttack(target.Value, height, power);
return true;
}
public bool SendAttack(AttackHeight height, float power) => false;
public void SendCancelAttack()
{
if (TryGetSession(out WorldSession? session))
session!.SendCancelAttack();
}
public bool IsDualWield => false;
public bool PlayerReadyForAttack => false;
public bool IsDualWield =>
RequireRuntime().MovementOwner.IsDualWield;
public bool PlayerReadyForAttack
{
get
{
GameRuntime runtime = RequireRuntime();
return runtime.MovementOwner.IsReadyForAttack(
runtime.ActionOwner.Combat.CurrentMode);
}
}
public bool AutoRepeatAttack => false;
public bool AutoTarget => false;
public uint? SelectClosestTarget() => null;
public bool AutoTarget => true;
public uint? SelectClosestTarget()
{
GameRuntime runtime = RequireRuntime();
uint? closest = RuntimeHostileTargetQuery.FindClosest(runtime);
if (closest is { } target)
{
runtime.ActionOwner.Selection.Select(
target,
AcDream.Core.Selection.SelectionChangeSource.Keyboard);
}
else
{
runtime.ActionOwner.Selection.Clear(
AcDream.Core.Selection.SelectionChangeSource.Keyboard);
}
return closest;
}
public bool IsInWorld => _runtime?.Session.IsInWorld == true;
public IReadOnlyList<ClientObject> GetOrderedEquipment() => [];
public IReadOnlyList<ClientObject> GetOrderedEquipment()
{
GameRuntime runtime = RequireRuntime();
return runtime.InventoryOwner.Objects.GetEquippedBy(
runtime.PlayerIdentity.ServerGuid);
}
public void NotifyExplicitCombatModeRequest()
{
}
public void SendChangeCombatMode(CombatMode mode)
{
if (TryGetSession(out WorldSession? session))
session!.SendChangeCombatMode(mode);
}
public uint LocalPlayerId =>
_runtime?.PlayerIdentity.ServerGuid ?? 0u;
public bool CanSend => false;
public bool HasRequiredComponents(uint spellId) => false;
public bool CanSend => TryGetSession(out _);
public bool HasRequiredComponents(uint spellId)
{
GameRuntime runtime = RequireRuntime();
ClientObject? player = runtime.InventoryOwner.Objects.Get(
runtime.PlayerIdentity.ServerGuid);
return player is not null
&& !player.Properties.GetBool(
SpellComponentsRequiredProperty,
true);
}
public bool IsTargetCompatible(
uint targetId,
SpellMetadata spell,
bool showMessage) => false;
bool showMessage)
{
GameRuntime runtime = RequireRuntime();
ClientObject? target =
runtime.InventoryOwner.Objects.Get(targetId);
if (target is null)
return false;
SpellTargetPolicyResult result =
RetailSpellTargetPolicy.Evaluate(
runtime.PlayerIdentity.ServerGuid,
target,
spell);
if (showMessage
&& !result.Allowed
&& result.Message is { } message)
{
DisplayMessage(message);
}
return result.Allowed;
}
public void StopCompletely()
{
_ = RequireRuntime().MovementOwner.PrepareForAttackRequest();
}
public void SendUntargeted(uint spellId)
{
if (TryGetSession(out WorldSession? session))
session!.SendCastUntargetedSpell(spellId);
}
public void SendTargeted(uint targetId, uint spellId)
{
if (TryGetSession(out WorldSession? session))
session!.SendCastTargetedSpell(targetId, spellId);
}
public void DisplayMessage(string message)
public void DisplayMessage(string message) =>
RequireRuntime().CommunicationOwner.Chat.OnSystemMessage(
message,
chatType: 0u);
public void IncrementBusy() =>
RequireRuntime().ActionOwner.Transactions
.IncrementBusyCount();
private uint? GetSelectedOrClosestTarget(GameRuntime runtime)
{
uint? selected =
runtime.ActionOwner.Selection.SelectedObjectId;
if (selected is { } target
&& RuntimeHostileTargetQuery.IsHostile(runtime, target))
{
return target;
}
return AutoTarget ? SelectClosestTarget() : null;
}
public void IncrementBusy()
private GameRuntime RequireRuntime() =>
_runtime
?? throw new InvalidOperationException(
"Headless gameplay operations are not bound.");
private bool TryGetSession(out WorldSession? session)
{
lock (_gate)
{
session = _session;
return _route is not null
&& session is not null
&& IsInWorld;
}
}
private void Activate(
WorldSession session,
SessionRoute route)
{
lock (_gate)
{
if (_route is not null)
{
throw new InvalidOperationException(
"Headless gameplay operations already have an active session.");
}
_session = session;
_route = route;
}
}
private void Deactivate(SessionRoute route)
{
lock (_gate)
{
if (!ReferenceEquals(_route, route))
return;
_session = null;
_route = null;
}
}
private const uint SpellComponentsRequiredProperty = 68u;
}

View file

@ -0,0 +1,124 @@
using AcDream.Core.Physics;
using AcDream.Runtime;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Session;
namespace AcDream.Headless.Hosting;
internal sealed class HeadlessMovementInputSource(
RuntimeLocalPlayerMovementState movement)
: IRuntimeMovementInputSource
{
private readonly RuntimeLocalPlayerMovementState _movement =
movement ?? throw new ArgumentNullException(nameof(movement));
public MovementInput Capture()
{
if (_movement.HasCommandInput)
return _movement.CommandInput;
return new MovementInput(
Forward: _movement.AutoRunActive,
Run: true);
}
}
internal sealed class HeadlessLocalPlayerFrameHost
: IRuntimeLocalPlayerFrameHost
{
private readonly GameRuntime _runtime;
private readonly LiveSessionHost _session;
private readonly LocalPlayerOutboundController _outbound;
internal HeadlessLocalPlayerFrameHost(
GameRuntime runtime,
LiveSessionHost session)
{
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
_session = session ?? throw new ArgumentNullException(nameof(session));
_outbound = new LocalPlayerOutboundController(
static (_, _, _, _, _, _) => { });
}
public bool CanAdvancePlayer =>
_runtime.Session.IsInWorld
&& _runtime.MovementOwner.Controller is not null;
public PlayerMovementController? Controller =>
_runtime.MovementOwner.Controller;
public uint ResolveLocalEntityId()
{
uint player = _runtime.PlayerIdentity.ServerGuid;
return player != 0u
&& _runtime.EntityObjects.Entities.TryGetActive(
player,
out var record)
? record.LocalEntityId ?? 0u
: 0u;
}
public void HandleTargeting()
{
}
public bool IsHidden
{
get
{
uint player = _runtime.PlayerIdentity.ServerGuid;
return player != 0u
&& _runtime.EntityObjects.Entities.TryGetActive(
player,
out var record)
&& (record.FinalPhysicsState
& PhysicsStateFlags.Hidden) != 0;
}
}
public RetailObjectClockDisposition ObjectClockDisposition
{
get
{
uint player = _runtime.PlayerIdentity.ServerGuid;
if (player == 0u
|| !_runtime.EntityObjects.Entities.TryGetActive(
player,
out var record)
|| record.FullCellId == 0u
|| (record.FinalPhysicsState
& (PhysicsStateFlags.Frozen
| PhysicsStateFlags.Static)) != 0)
{
return RetailObjectClockDisposition.Suspend;
}
return RetailObjectClockDisposition.Advance;
}
}
public void Project(
PlayerMovementController controller,
MovementResult movement,
bool hidden)
{
// The controller and Runtime physics body are the direct host's
// canonical projection. There is no render bucket or shadow mirror.
}
public void SendPreNetwork(
PlayerMovementController controller,
MovementResult movement,
bool hidden) =>
_outbound.SendPreNetworkActions(
_session.CurrentSession,
controller,
movement,
hidden);
public void SendPostNetwork(
PlayerMovementController controller,
bool hidden) =>
_outbound.SendPostNetworkPosition(
_session.CurrentSession,
controller,
hidden);
}

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);
}