feat(headless): complete deterministic bot command parity
This commit is contained in:
parent
7e8acb74dd
commit
38e83640d9
37 changed files with 2805 additions and 295 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
124
src/AcDream.Headless/Hosting/HeadlessLocalPlayerFrameHost.cs
Normal file
124
src/AcDream.Headless/Hosting/HeadlessLocalPlayerFrameHost.cs
Normal 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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue