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,3 +1,5 @@
using System.Diagnostics;
using AcDream.Core.Combat;
using AcDream.Core.Physics;
namespace AcDream.Runtime.Gameplay;
@ -26,6 +28,8 @@ public sealed class RuntimeLocalPlayerMovementState
private PlayerMovementController? _controller;
private PlayerMovementController? _preparingMotionOwner;
private bool _autoRunActive;
private bool _hasCommandInput;
private MovementInput _commandInput;
private bool _disposed;
private long _revision;
@ -43,6 +47,8 @@ public sealed class RuntimeLocalPlayerMovementState
}
public bool AutoRunActive => _autoRunActive;
public bool HasCommandInput => _hasCommandInput;
public MovementInput CommandInput => _commandInput;
public long Revision => Interlocked.Read(ref _revision);
public IRuntimeMovementView View => this;
@ -63,7 +69,9 @@ public sealed class RuntimeLocalPlayerMovementState
false,
0d,
Revision,
_autoRunActive)
_autoRunActive,
_hasCommandInput,
_commandInput)
: new RuntimeMovementSnapshot(
true,
controller.LocalEntityId,
@ -72,7 +80,9 @@ public sealed class RuntimeLocalPlayerMovementState
controller.IsAirborne,
controller.SimTimeSeconds,
Revision,
_autoRunActive);
_autoRunActive,
_hasCommandInput,
_commandInput);
}
}
@ -109,7 +119,27 @@ public sealed class RuntimeLocalPlayerMovementState
return true;
case RuntimeMovementCommand.Stop:
CancelAutoRun();
ClearCommandInput();
return true;
case RuntimeMovementCommand.Ready:
case RuntimeMovementCommand.Sit:
case RuntimeMovementCommand.Crouch:
case RuntimeMovementCommand.Sleep:
CancelAutoRun();
ClearCommandInput();
uint motion = command switch
{
RuntimeMovementCommand.Ready =>
MotionCommand.Ready,
RuntimeMovementCommand.Sit =>
MotionCommand.Sitting,
RuntimeMovementCommand.Crouch =>
MotionCommand.Crouch,
RuntimeMovementCommand.Sleep =>
MotionCommand.Sleeping,
_ => throw new UnreachableException(),
};
return _controller?.RequestPosture(motion) == true;
default:
return false;
}
@ -125,12 +155,75 @@ public sealed class RuntimeLocalPlayerMovementState
return true;
}
/// <summary>
/// Installs the persistent semantic movement level sampled by either host.
/// The graphical input adapter and direct host both consume this exact
/// owner; no bot-only movement latch exists.
/// </summary>
public void SetCommandInput(in MovementInput input)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_hasCommandInput && _commandInput == input)
return;
_commandInput = input;
_hasCommandInput = true;
Interlocked.Increment(ref _revision);
}
public bool ClearCommandInput()
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (!_hasCommandInput)
return false;
_commandInput = default;
_hasCommandInput = false;
Interlocked.Increment(ref _revision);
return true;
}
/// <summary>
/// Direct-host projection of the same combat readiness query used by the
/// graphical attack adapter. A host without a constructed local movement
/// owner is not ready; Slice K's content host must hydrate that owner
/// before combat packets can leave.
/// </summary>
public bool IsReadyForAttack(CombatMode mode)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_controller is not { } controller)
return false;
var motion = controller.Motion.InterpretedState;
return CombatInputPlanner.PlayerInReadyPositionForAttack(
mode,
motion.CurrentStyle,
motion.ForwardCommand);
}
public bool IsDualWield
{
get
{
ObjectDisposedException.ThrowIf(_disposed, this);
return _controller?.Motion.InterpretedState.CurrentStyle
== CombatInputPlanner.DualWieldCombatStyle;
}
}
public bool PrepareForAttackRequest()
{
ObjectDisposedException.ThrowIf(_disposed, this);
CancelAutoRun();
return _controller?.PrepareForAttackRequest() == true;
}
public void ResetInputIntent()
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (!_autoRunActive)
if (!_autoRunActive && !_hasCommandInput)
return;
_autoRunActive = false;
_hasCommandInput = false;
_commandInput = default;
Interlocked.Increment(ref _revision);
}
@ -145,9 +238,12 @@ public sealed class RuntimeLocalPlayerMovementState
ObjectDisposedException.ThrowIf(_disposed, this);
bool changed =
_autoRunActive
|| _hasCommandInput
|| _controller is not null
|| _preparingMotionOwner is not null;
_autoRunActive = false;
_hasCommandInput = false;
_commandInput = default;
_controller = null;
_preparingMotionOwner = null;
if (changed)
@ -160,6 +256,7 @@ public sealed class RuntimeLocalPlayerMovementState
_controller is not null,
_preparingMotionOwner is not null,
_autoRunActive,
_hasCommandInput,
Revision);
public void Dispose()
@ -167,6 +264,8 @@ public sealed class RuntimeLocalPlayerMovementState
if (_disposed)
return;
_autoRunActive = false;
_hasCommandInput = false;
_commandInput = default;
_controller = null;
_preparingMotionOwner = null;
Interlocked.Increment(ref _revision);
@ -210,11 +309,13 @@ public readonly record struct RuntimeLocalMovementOwnershipSnapshot(
bool HasController,
bool HasPreparingMotionOwner,
bool AutoRunActive,
bool HasCommandInput,
long Revision)
{
public bool IsConverged =>
IsDisposed
&& !HasController
&& !HasPreparingMotionOwner
&& !AutoRunActive;
&& !AutoRunActive
&& !HasCommandInput;
}