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

@ -686,6 +686,7 @@ internal sealed class SessionPlayerCompositionPhase
d.PlayerOutbound,
liveSessionSource);
var localPlayerFrame = new RetailLocalPlayerFrameController(
d.Runtime,
localPlayerFrameRuntime,
d.MovementInput);
var liveObjectFrame = new LiveObjectFrameController(

View file

@ -3,8 +3,8 @@ using AcDream.UI.Abstractions.Input;
namespace AcDream.App.Input;
internal interface IMovementInputSource
: IRuntimeMovementInputSource
{
MovementInput Capture();
}
/// <summary>
@ -53,6 +53,9 @@ internal sealed class DispatcherMovementInputSource : IMovementInputSource
if (_capture?.DevToolsWantCaptureKeyboard == true)
return default;
if (_movement.HasCommandInput)
return _movement.CommandInput;
if (_dispatcher is not { } dispatcher)
return default;

View file

@ -17,15 +17,12 @@ internal interface ILocalPlayerPresentationRuntime
PlayerMovementController? Controller { get; }
}
internal interface ILocalPlayerFrameRuntime : ILocalPlayerPresentationRuntime
internal interface ILocalPlayerFrameRuntime :
ILocalPlayerPresentationRuntime,
IRuntimeLocalPlayerFrameHost
{
uint ResolveLocalEntityId();
void HandleTargeting();
bool IsHidden { get; }
RetailObjectClockDisposition ObjectClockDisposition { get; }
void Project(PlayerMovementController controller, MovementResult movement, bool hidden);
void SendPreNetwork(PlayerMovementController controller, MovementResult movement, bool hidden);
void SendPostNetwork(PlayerMovementController controller, bool hidden);
bool IRuntimeLocalPlayerFrameHost.CanAdvancePlayer =>
CanPresentPlayer;
}
/// <summary>

View file

@ -1,4 +1,5 @@
using AcDream.App.Update;
using AcDream.Runtime;
namespace AcDream.App.Input;
@ -14,17 +15,7 @@ namespace AcDream.App.Input;
/// </remarks>
internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFramePhase
{
private readonly ILocalPlayerFrameRuntime _runtime;
private readonly IMovementInputSource _movementInput;
private AdvancedFrame? _advancedFrame;
private readonly record struct AdvancedFrame(
PlayerMovementController Controller,
MovementResult Movement,
bool ObjectAdvanced,
bool Hidden,
bool ObjectQuantumAdvanced);
private readonly RuntimeLocalPlayerFrameController _runtime;
public readonly record struct PresentationFrame(
MovementResult Movement,
@ -35,19 +26,29 @@ internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFram
/// Whether the pre-network Hidden player update admitted a complete object
/// quantum whose retained CPartArray pose must be sampled this frame.
/// </summary>
internal bool HiddenPartPoseDirty => _advancedFrame is
{
Hidden: true,
ObjectQuantumAdvanced: true,
};
internal bool HiddenPartPoseDirty =>
_runtime.HiddenPartPoseDirty;
public RetailLocalPlayerFrameController(
ILocalPlayerFrameRuntime runtime,
IMovementInputSource movementInput)
{
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
_movementInput = movementInput
?? throw new ArgumentNullException(nameof(movementInput));
_runtime = new RuntimeLocalPlayerFrameController(
runtime ?? throw new ArgumentNullException(nameof(runtime)),
movementInput
?? throw new ArgumentNullException(nameof(movementInput)));
}
public RetailLocalPlayerFrameController(
GameRuntime gameRuntime,
ILocalPlayerFrameRuntime runtime,
IMovementInputSource movementInput)
{
ArgumentNullException.ThrowIfNull(gameRuntime);
_runtime = gameRuntime.CreateLocalPlayerFrameController(
runtime ?? throw new ArgumentNullException(nameof(runtime)),
movementInput
?? throw new ArgumentNullException(nameof(movementInput)));
}
/// <summary>
@ -55,64 +56,7 @@ internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFram
/// the inbound-network barrier.
/// </summary>
public void AdvanceBeforeNetwork(float deltaSeconds)
{
_advancedFrame = null;
PlayerMovementController? controller = _runtime.Controller;
if (!_runtime.CanPresentPlayer || controller is null)
return;
if (!float.IsFinite(deltaSeconds) || deltaSeconds <= 0f)
{
bool rejectedHidden = _runtime.IsHidden;
MovementResult rejected = controller.CapturePresentationResult();
_runtime.Project(controller, rejected, rejectedHidden);
_advancedFrame = new AdvancedFrame(
controller,
rejected,
ObjectAdvanced: false,
Hidden: rejectedHidden,
ObjectQuantumAdvanced: false);
return;
}
controller.LocalEntityId = _runtime.ResolveLocalEntityId();
bool hidden = _runtime.IsHidden;
if (_runtime.ObjectClockDisposition
is AcDream.Core.Physics.RetailObjectClockDisposition.Suspend)
{
// CPhysicsObj::update_object returns before advancing time for a
// Frozen or cell-less object. Keep the retained projection live,
// but emit neither input actions nor AutonomousPosition from a
// physics tick that retail did not run.
controller.SuspendObjectUpdate(deltaSeconds);
MovementResult paused = controller.CapturePresentationResult();
_runtime.Project(controller, paused, hidden);
_advancedFrame = new AdvancedFrame(
controller,
paused,
ObjectAdvanced: false,
Hidden: hidden,
ObjectQuantumAdvanced: false);
return;
}
MovementResult movement = hidden
? controller.TickHidden(deltaSeconds, _runtime.HandleTargeting)
: controller.Update(
deltaSeconds,
_movementInput.Capture(),
_runtime.HandleTargeting);
_runtime.Project(controller, movement, hidden);
_runtime.SendPreNetwork(controller, movement, hidden);
_advancedFrame = new AdvancedFrame(
controller,
movement,
ObjectAdvanced: true,
Hidden: hidden,
ObjectQuantumAdvanced: controller.AdvancedObjectQuantumLastTick);
}
=> _runtime.AdvanceBeforeNetwork(deltaSeconds);
/// <summary>
/// Runs retail's post-inbound command-interpreter phase. The split
@ -120,32 +64,7 @@ internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFram
/// then AutonomousPosition is evaluated from that current state.
/// </summary>
public void RunPostNetworkCommandPhase()
{
PlayerMovementController? controller = _runtime.Controller;
if (!_runtime.CanPresentPlayer || controller is null)
return;
bool hidden = _runtime.IsHidden;
if (_advancedFrame is { } advanced
&& ReferenceEquals(advanced.Controller, controller))
{
MovementResult movement = RefreshSpatialFields(
advanced.Movement,
controller);
_runtime.Project(controller, movement, hidden);
_advancedFrame = new AdvancedFrame(
controller,
movement,
advanced.ObjectAdvanced,
advanced.Hidden,
advanced.ObjectQuantumAdvanced);
if (!advanced.ObjectAdvanced)
return;
}
_runtime.SendPostNetwork(controller, hidden);
}
=> _runtime.RunPostNetworkCommandPhase();
/// <summary>
/// Produces the post-network presentation sample without advancing
@ -154,39 +73,15 @@ internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFram
/// </summary>
public bool TryGetPresentationAfterNetwork(out PresentationFrame frame)
{
frame = default;
PlayerMovementController? controller = _runtime.Controller;
if (!_runtime.CanPresentPlayer || controller is null)
return false;
bool hidden = _runtime.IsHidden;
if (_advancedFrame is { } advanced
&& ReferenceEquals(advanced.Controller, controller))
{
MovementResult movement = RefreshSpatialFields(
advanced.Movement,
controller);
frame = new PresentationFrame(
movement,
hidden,
AdvancedBeforeNetwork: advanced.ObjectAdvanced);
return true;
}
MovementResult initial = controller.CapturePresentationResult();
_runtime.Project(controller, initial, hidden);
frame = new PresentationFrame(initial, hidden, AdvancedBeforeNetwork: false);
return true;
bool available = _runtime.TryGetPresentationAfterNetwork(
out AcDream.Runtime.Gameplay.RuntimeLocalPlayerPresentationFrame
shared);
frame = available
? new PresentationFrame(
shared.Movement,
shared.Hidden,
shared.AdvancedBeforeNetwork)
: default;
return available;
}
private static MovementResult RefreshSpatialFields(
MovementResult movement,
PlayerMovementController controller) =>
movement with
{
Position = controller.Position,
RenderPosition = controller.RenderPosition,
CellId = controller.CellId,
IsOnGround = controller.CanSendPositionEvent,
};
}

View file

@ -2,6 +2,7 @@ using AcDream.App.Input;
using AcDream.App.Interaction;
using AcDream.App.Net;
using AcDream.Core.Items;
using AcDream.Core.Selection;
using AcDream.Runtime;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Session;
@ -164,6 +165,51 @@ internal sealed class CurrentGameRuntimeCommandAdapter
return Result(status, selected);
}
public RuntimeCommandResult SelectObject(
RuntimeGenerationToken expectedGeneration,
uint objectId)
{
RuntimeCommandStatus gate = Validate(
expectedGeneration,
requireWorld: true);
if (gate != RuntimeCommandStatus.Accepted)
return Result(gate);
RuntimeCommandStatus status =
objectId != 0u
&& _inventory.Objects.Get(objectId) is not null
? RuntimeCommandStatus.Accepted
: RuntimeCommandStatus.Rejected;
if (status == RuntimeCommandStatus.Accepted)
{
_actions.Selection.Select(
objectId,
SelectionChangeSource.Plugin);
}
_events.EmitCommand(
RuntimeCommandDomain.Selection,
operation: 0x100,
status,
objectId);
return Result(status, objectId);
}
public RuntimeCommandResult Clear(
RuntimeGenerationToken expectedGeneration)
{
RuntimeCommandStatus gate = Validate(
expectedGeneration,
requireWorld: true);
if (gate != RuntimeCommandStatus.Accepted)
return Result(gate);
_actions.Selection.Clear(SelectionChangeSource.Plugin);
_events.EmitCommand(
RuntimeCommandDomain.Selection,
operation: 0x101,
RuntimeCommandStatus.Accepted);
return Result(RuntimeCommandStatus.Accepted);
}
public RuntimeCommandResult Execute(
RuntimeGenerationToken expectedGeneration,
RuntimeCombatCommand command)
@ -254,6 +300,39 @@ internal sealed class CurrentGameRuntimeCommandAdapter
return Result(status);
}
public RuntimeCommandResult SetIntent(
RuntimeGenerationToken expectedGeneration,
in MovementInput input)
{
RuntimeCommandStatus gate = Validate(
expectedGeneration,
requireWorld: true);
if (gate != RuntimeCommandStatus.Accepted)
return Result(gate);
_movement.SetCommandInput(input);
_events.EmitCommand(
RuntimeCommandDomain.Movement,
operation: 0x100,
RuntimeCommandStatus.Accepted);
return Result(RuntimeCommandStatus.Accepted);
}
public RuntimeCommandResult ClearIntent(
RuntimeGenerationToken expectedGeneration)
{
RuntimeCommandStatus gate = Validate(
expectedGeneration,
requireWorld: true);
if (gate != RuntimeCommandStatus.Accepted)
return Result(gate);
_movement.ClearCommandInput();
_events.EmitCommand(
RuntimeCommandDomain.Movement,
operation: 0x101,
RuntimeCommandStatus.Accepted);
return Result(RuntimeCommandStatus.Accepted);
}
public RuntimeCommandResult Execute(
RuntimeGenerationToken expectedGeneration,
in RuntimeChatCommand command)

View file

@ -1,45 +0,0 @@
using AcDream.Core.Items;
using AcDream.Core.Spells;
namespace AcDream.App.Spells;
public readonly record struct SpellTargetPolicyResult(bool Allowed, string? Message)
{
public static SpellTargetPolicyResult Accept { get; } = new(true, null);
}
/// <summary>
/// Pure projection of ClientMagicSystem::ObjectCompatibleWithSpellTargetType
/// (0x00567230). The caller owns target lookup and message presentation.
/// </summary>
public static class RetailSpellTargetPolicy
{
private const uint SpecialTargetMask = 0x00008107u;
public static SpellTargetPolicyResult Evaluate(
uint localPlayerId,
ClientObject target,
SpellMetadata spell)
{
uint mask = spell.TargetMask;
uint special = mask & SpecialTargetMask;
if (target.ObjectId == localPlayerId && special == 0u)
return new(false, "You cannot cast this spell upon yourself.");
if (target.StackSize > 1)
return new(false, "Cannot cast spell on a stack of items.");
if (((uint)target.Type & mask) == 0u && special == 0u)
return new(false, $"This spell cannot be cast on {target.Name}.");
// Retail joins the ordinary type match and the 0x8107 special-mask
// bypass before these two gates. IsPlayer is PWD bit 3; otherwise the
// object must carry BF_ATTACKABLE. Pets are never legal spell targets.
var flags = (PublicWeenieFlags)target.PublicWeenieBitfield.GetValueOrDefault();
bool playerOrAttackable = (flags
& (PublicWeenieFlags.Player | PublicWeenieFlags.Attackable)) != 0;
if (!playerOrAttackable || target.PetOwnerId != 0u)
return new(false, $"This spell cannot be cast on {target.Name}.");
return SpellTargetPolicyResult.Accept;
}
}