diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs
index 7ffe2f50..4d60a938 100644
--- a/src/AcDream.App/Composition/SessionPlayerComposition.cs
+++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs
@@ -686,6 +686,7 @@ internal sealed class SessionPlayerCompositionPhase
d.PlayerOutbound,
liveSessionSource);
var localPlayerFrame = new RetailLocalPlayerFrameController(
+ d.Runtime,
localPlayerFrameRuntime,
d.MovementInput);
var liveObjectFrame = new LiveObjectFrameController(
diff --git a/src/AcDream.App/Input/DispatcherMovementInputSource.cs b/src/AcDream.App/Input/DispatcherMovementInputSource.cs
index 2bc27f4f..af2ca7a5 100644
--- a/src/AcDream.App/Input/DispatcherMovementInputSource.cs
+++ b/src/AcDream.App/Input/DispatcherMovementInputSource.cs
@@ -3,8 +3,8 @@ using AcDream.UI.Abstractions.Input;
namespace AcDream.App.Input;
internal interface IMovementInputSource
+ : IRuntimeMovementInputSource
{
- MovementInput Capture();
}
///
@@ -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;
diff --git a/src/AcDream.App/Input/LocalPlayerFrameRuntime.cs b/src/AcDream.App/Input/LocalPlayerFrameRuntime.cs
index da3f4645..36f2797d 100644
--- a/src/AcDream.App/Input/LocalPlayerFrameRuntime.cs
+++ b/src/AcDream.App/Input/LocalPlayerFrameRuntime.cs
@@ -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;
}
///
diff --git a/src/AcDream.App/Input/RetailLocalPlayerFrameController.cs b/src/AcDream.App/Input/RetailLocalPlayerFrameController.cs
index 0d8d1356..ca4ee1b9 100644
--- a/src/AcDream.App/Input/RetailLocalPlayerFrameController.cs
+++ b/src/AcDream.App/Input/RetailLocalPlayerFrameController.cs
@@ -1,4 +1,5 @@
using AcDream.App.Update;
+using AcDream.Runtime;
namespace AcDream.App.Input;
@@ -14,17 +15,7 @@ namespace AcDream.App.Input;
///
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.
///
- 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)));
}
///
@@ -55,64 +56,7 @@ internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFram
/// the inbound-network barrier.
///
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);
///
/// 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.
///
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();
///
/// Produces the post-network presentation sample without advancing
@@ -154,39 +73,15 @@ internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFram
///
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,
- };
}
diff --git a/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs b/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs
index 0a101d2c..64eb0cdc 100644
--- a/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs
+++ b/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs
@@ -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)
diff --git a/src/AcDream.Core.Net/AcDream.Core.Net.csproj b/src/AcDream.Core.Net/AcDream.Core.Net.csproj
index a31b915c..fc204382 100644
--- a/src/AcDream.Core.Net/AcDream.Core.Net.csproj
+++ b/src/AcDream.Core.Net/AcDream.Core.Net.csproj
@@ -14,5 +14,6 @@
+
diff --git a/src/AcDream.App/Spells/RetailSpellTargetPolicy.cs b/src/AcDream.Core/Spells/RetailSpellTargetPolicy.cs
similarity index 72%
rename from src/AcDream.App/Spells/RetailSpellTargetPolicy.cs
rename to src/AcDream.Core/Spells/RetailSpellTargetPolicy.cs
index e21b1302..062d3192 100644
--- a/src/AcDream.App/Spells/RetailSpellTargetPolicy.cs
+++ b/src/AcDream.Core/Spells/RetailSpellTargetPolicy.cs
@@ -1,15 +1,18 @@
using AcDream.Core.Items;
-using AcDream.Core.Spells;
-namespace AcDream.App.Spells;
+namespace AcDream.Core.Spells;
-public readonly record struct SpellTargetPolicyResult(bool Allowed, string? Message)
+public readonly record struct SpellTargetPolicyResult(
+ bool Allowed,
+ string? Message)
{
- public static SpellTargetPolicyResult Accept { get; } = new(true, null);
+ public static SpellTargetPolicyResult Accept { get; } =
+ new(true, null);
}
///
-/// Pure projection of ClientMagicSystem::ObjectCompatibleWithSpellTargetType
+/// Pure projection of
+/// ClientMagicSystem::ObjectCompatibleWithSpellTargetType
/// (0x00567230). The caller owns target lookup and message presentation.
///
public static class RetailSpellTargetPolicy
@@ -34,9 +37,11 @@ public static class RetailSpellTargetPolicy
// 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();
+ var flags = (PublicWeenieFlags)
+ target.PublicWeenieBitfield.GetValueOrDefault();
bool playerOrAttackable = (flags
- & (PublicWeenieFlags.Player | PublicWeenieFlags.Attackable)) != 0;
+ & (PublicWeenieFlags.Player
+ | PublicWeenieFlags.Attackable)) != 0;
if (!playerOrAttackable || target.PetOwnerId != 0u)
return new(false, $"This spell cannot be cast on {target.Name}.");
diff --git a/src/AcDream.Headless/Hosting/HeadlessGameplayOperations.cs b/src/AcDream.Headless/Hosting/HeadlessGameplayOperations.cs
index 2a2633d1..d84a5d9a 100644
--- a/src/AcDream.Headless/Hosting/HeadlessGameplayOperations.cs
+++ b/src/AcDream.Headless/Hosting/HeadlessGameplayOperations.cs
@@ -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 GetOrderedEquipment() => [];
+ public IReadOnlyList 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;
}
diff --git a/src/AcDream.Headless/Hosting/HeadlessLocalPlayerFrameHost.cs b/src/AcDream.Headless/Hosting/HeadlessLocalPlayerFrameHost.cs
new file mode 100644
index 00000000..d8c649b3
--- /dev/null
+++ b/src/AcDream.Headless/Hosting/HeadlessLocalPlayerFrameHost.cs
@@ -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);
+}
diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs
index 4f8f2de2..72ba48a1 100644
--- a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs
+++ b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs
@@ -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? 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);
}
diff --git a/src/AcDream.Headless/Policies/HeadlessBotPolicy.cs b/src/AcDream.Headless/Policies/HeadlessBotPolicy.cs
index 5a966537..2ac23bf8 100644
--- a/src/AcDream.Headless/Policies/HeadlessBotPolicy.cs
+++ b/src/AcDream.Headless/Policies/HeadlessBotPolicy.cs
@@ -62,6 +62,10 @@ internal sealed class IdleHeadlessBotPolicy : IHeadlessBotPolicy
{
}
+ public void OnCombat(in RuntimeCombatDelta delta)
+ {
+ }
+
public void Dispose()
{
}
@@ -161,6 +165,10 @@ internal sealed class LifecycleSmokeHeadlessBotPolicy
{
}
+ public void OnCombat(in RuntimeCombatDelta delta)
+ {
+ }
+
public void Dispose()
{
}
diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs
index c1444257..43e1afc8 100644
--- a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs
+++ b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs
@@ -94,10 +94,11 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
private bool _disposed;
public RuntimeEntityObjectLifetime(
- uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId)
+ uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId,
+ TimeProvider? timeProvider = null)
{
Entities = new RuntimeEntityDirectory(firstLocalEntityId);
- Physics = new RuntimePhysicsState(Entities);
+ Physics = new RuntimePhysicsState(Entities, timeProvider: timeProvider);
Objects = new ClientObjectTable();
var views = new RuntimeEntityObjectViews(Entities, Objects);
EntityView = views.Entities;
@@ -107,11 +108,15 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
internal RuntimeEntityObjectLifetime(
PhysicsDataCache physicsDataCache,
- uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId)
+ uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId,
+ TimeProvider? timeProvider = null)
{
ArgumentNullException.ThrowIfNull(physicsDataCache);
Entities = new RuntimeEntityDirectory(firstLocalEntityId);
- Physics = new RuntimePhysicsState(Entities, physicsDataCache);
+ Physics = new RuntimePhysicsState(
+ Entities,
+ physicsDataCache,
+ timeProvider);
Objects = new ClientObjectTable();
var views = new RuntimeEntityObjectViews(Entities, Objects);
EntityView = views.Entities;
@@ -121,11 +126,15 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
internal RuntimeEntityObjectLifetime(
PhysicsEngine physicsEngine,
- uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId)
+ uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId,
+ TimeProvider? timeProvider = null)
{
ArgumentNullException.ThrowIfNull(physicsEngine);
Entities = new RuntimeEntityDirectory(firstLocalEntityId);
- Physics = new RuntimePhysicsState(Entities, physicsEngine);
+ Physics = new RuntimePhysicsState(
+ Entities,
+ physicsEngine,
+ timeProvider);
Objects = new ClientObjectTable();
var views = new RuntimeEntityObjectViews(Entities, Objects);
EntityView = views.Entities;
diff --git a/src/AcDream.Runtime/GameRuntime.cs b/src/AcDream.Runtime/GameRuntime.cs
index f6b5be4f..fcad9f14 100644
--- a/src/AcDream.Runtime/GameRuntime.cs
+++ b/src/AcDream.Runtime/GameRuntime.cs
@@ -177,7 +177,8 @@ public sealed class GameRuntime
faultInjection);
context.EntityObjects = new RuntimeEntityObjectLifetime(
- dependencies.FirstLocalEntityId);
+ dependencies.FirstLocalEntityId,
+ dependencies.TimeProvider);
construction.Own(context.EntityObjects);
Fault(
GameRuntimeConstructionPoint.EntityObjectsCreated,
@@ -259,7 +260,8 @@ public sealed class GameRuntime
() => clock.FrameNumber);
context.Events = new GameRuntimeEventHub(
context.EntityObjects,
- context.Communication);
+ context.Communication,
+ context.Actions);
construction.Own(context.Events);
Fault(
GameRuntimeConstructionPoint.EventsCreated,
@@ -362,6 +364,17 @@ public sealed class GameRuntime
Portal.Snapshot,
Portal.Ownership);
+ public RuntimeLocalPlayerFrameController CreateLocalPlayerFrameController(
+ IRuntimeLocalPlayerFrameHost host,
+ IRuntimeMovementInputSource input)
+ {
+ ObjectDisposedException.ThrowIf(_disposeRequested || _disposed, this);
+ return new RuntimeLocalPlayerFrameController(
+ host,
+ input,
+ () => _events.EmitMovement(MovementOwner.Snapshot));
+ }
+
public void ResetGeneration(
RuntimeGenerationToken retiringGeneration,
IRuntimeGenerationResetHost host) =>
diff --git a/src/AcDream.Runtime/GameRuntimeCommands.cs b/src/AcDream.Runtime/GameRuntimeCommands.cs
index 2f7271a6..f03d7836 100644
--- a/src/AcDream.Runtime/GameRuntimeCommands.cs
+++ b/src/AcDream.Runtime/GameRuntimeCommands.cs
@@ -105,6 +105,13 @@ public interface IRuntimeSelectionCommands
RuntimeCommandResult Execute(
RuntimeGenerationToken expectedGeneration,
RuntimeSelectionCommand command);
+
+ RuntimeCommandResult SelectObject(
+ RuntimeGenerationToken expectedGeneration,
+ uint objectId);
+
+ RuntimeCommandResult Clear(
+ RuntimeGenerationToken expectedGeneration);
}
public interface IRuntimeCombatCommands
@@ -132,6 +139,13 @@ public interface IRuntimeMovementCommands
RuntimeCommandResult Execute(
RuntimeGenerationToken expectedGeneration,
RuntimeMovementCommand command);
+
+ RuntimeCommandResult SetIntent(
+ RuntimeGenerationToken expectedGeneration,
+ in Gameplay.MovementInput input);
+
+ RuntimeCommandResult ClearIntent(
+ RuntimeGenerationToken expectedGeneration);
}
public interface IRuntimeChatCommands
diff --git a/src/AcDream.Runtime/GameRuntimeEventHub.cs b/src/AcDream.Runtime/GameRuntimeEventHub.cs
index 76e6c76b..ce5072a1 100644
--- a/src/AcDream.Runtime/GameRuntimeEventHub.cs
+++ b/src/AcDream.Runtime/GameRuntimeEventHub.cs
@@ -48,21 +48,26 @@ internal sealed class GameRuntimeEventHub
{
private readonly RuntimeEntityObjectLifetime _entityObjects;
private readonly RuntimeCommunicationState _communication;
+ private readonly RuntimeActionState _actions;
private readonly object _observerGate = new();
private IRuntimeEventObserver[] _observers = [];
private IDisposable? _entityObjectSubscription;
private IDisposable? _communicationSubscription;
+ private bool _actionEventsAttached;
private bool _disposeRequested;
private bool _disposed;
public GameRuntimeEventHub(
RuntimeEntityObjectLifetime entityObjects,
- RuntimeCommunicationState communication)
+ RuntimeCommunicationState communication,
+ RuntimeActionState actions)
{
_entityObjects = entityObjects
?? throw new ArgumentNullException(nameof(entityObjects));
_communication = communication
?? throw new ArgumentNullException(nameof(communication));
+ _actions = actions
+ ?? throw new ArgumentNullException(nameof(actions));
}
public long DispatchFailureCount { get; private set; }
@@ -127,6 +132,7 @@ internal sealed class GameRuntimeEventHub
ref _entityObjectSubscription,
"entity/object event subscription",
ref failures);
+ DetachActionEvents();
_disposed = !OwnerEventsAttached;
if (failures is not null)
@@ -284,7 +290,8 @@ internal sealed class GameRuntimeEventHub
private bool OwnerEventsAttached =>
_entityObjectSubscription is not null
- || _communicationSubscription is not null;
+ || _communicationSubscription is not null
+ || _actionEventsAttached;
private void AttachOwnerEvents()
{
@@ -294,11 +301,14 @@ internal sealed class GameRuntimeEventHub
{
entity = _entityObjects.Events.Subscribe(this);
communication = _communication.Events.Subscribe(this);
+ _actions.CombatChanged += OnCombatChanged;
+ _actionEventsAttached = true;
_entityObjectSubscription = entity;
_communicationSubscription = communication;
}
catch
{
+ DetachActionEvents();
communication?.Dispose();
entity?.Dispose();
throw;
@@ -347,6 +357,7 @@ internal sealed class GameRuntimeEventHub
ref _entityObjectSubscription,
"entity/object event subscription",
ref failures);
+ DetachActionEvents();
if (failures is not null)
{
throw new AggregateException(
@@ -378,6 +389,37 @@ internal sealed class GameRuntimeEventHub
private RuntimeEventStamp NextStamp() =>
_entityObjects.Events.NextStamp();
+ private void OnCombatChanged()
+ {
+ if (!TryObservers(out IRuntimeEventObserver[] observers))
+ return;
+ RuntimeActionSnapshot actions = _actions.View.Snapshot;
+ var delta = new RuntimeCombatDelta(
+ NextStamp(),
+ actions.CombatMode,
+ actions.TrackedTargetHealthCount,
+ actions.CombatAttack);
+ foreach (IRuntimeEventObserver observer in observers)
+ {
+ try
+ {
+ observer.OnCombat(in delta);
+ }
+ catch (Exception error)
+ {
+ RecordDispatchFailure(error);
+ }
+ }
+ }
+
+ private void DetachActionEvents()
+ {
+ if (!_actionEventsAttached)
+ return;
+ _actions.CombatChanged -= OnCombatChanged;
+ _actionEventsAttached = false;
+ }
+
private bool TryObservers(out IRuntimeEventObserver[] observers)
{
observers = Volatile.Read(ref _observers);
diff --git a/src/AcDream.Runtime/GameRuntimeEvents.cs b/src/AcDream.Runtime/GameRuntimeEvents.cs
index b2d03b1d..376a3426 100644
--- a/src/AcDream.Runtime/GameRuntimeEvents.cs
+++ b/src/AcDream.Runtime/GameRuntimeEvents.cs
@@ -1,3 +1,5 @@
+using AcDream.Core.Combat;
+
namespace AcDream.Runtime;
public readonly record struct RuntimeEventStamp(
@@ -82,6 +84,12 @@ public readonly record struct RuntimePortalDelta(
RuntimeEventStamp Stamp,
RuntimePortalSnapshot Portal);
+public readonly record struct RuntimeCombatDelta(
+ RuntimeEventStamp Stamp,
+ CombatMode Mode,
+ int TrackedTargetCount,
+ RuntimeCombatAttackSnapshot Attack);
+
public interface IRuntimeEventObserver
{
void OnLifecycle(in RuntimeLifecycleDelta delta);
@@ -97,6 +105,8 @@ public interface IRuntimeEventObserver
void OnMovement(in RuntimeMovementDelta delta);
void OnPortal(in RuntimePortalDelta delta);
+
+ void OnCombat(in RuntimeCombatDelta delta);
}
public interface IRuntimeEventSource
@@ -113,6 +123,7 @@ public enum RuntimeTraceKind
Chat,
Movement,
Portal,
+ Combat,
Checkpoint,
}
@@ -278,6 +289,20 @@ public sealed class RuntimeTraceRecorder : IRuntimeEventObserver
$"{delta.Portal.IsReady}:{delta.Portal.IsMaterialized}:" +
$"{delta.Portal.IsCompleted}:{delta.Portal.IsCancelled}:" +
$"{delta.Portal.IsWorldVisible}"));
+
+ public void OnCombat(in RuntimeCombatDelta delta) =>
+ _entries.Add(new RuntimeTraceEntry(
+ delta.Stamp,
+ RuntimeTraceKind.Combat,
+ (int)delta.Mode,
+ delta.Attack.Revision,
+ (uint)delta.TrackedTargetCount,
+ 0u,
+ $"{(int)delta.Attack.RequestedHeight}:" +
+ $"{BitConverter.SingleToInt32Bits(delta.Attack.DesiredPower):X8}:" +
+ $"{BitConverter.SingleToInt32Bits(delta.Attack.PowerBarLevel):X8}:" +
+ $"{delta.Attack.BuildInProgress}:" +
+ $"{delta.Attack.RequestInProgress}"));
}
/// Monotonic sequence owner for one runtime instance.
diff --git a/src/AcDream.Runtime/GameRuntimeViews.cs b/src/AcDream.Runtime/GameRuntimeViews.cs
index bccbfc33..924651dd 100644
--- a/src/AcDream.Runtime/GameRuntimeViews.cs
+++ b/src/AcDream.Runtime/GameRuntimeViews.cs
@@ -78,7 +78,9 @@ public readonly record struct RuntimeMovementSnapshot(
bool IsAirborne,
double SimulationTimeSeconds,
long Revision = 0,
- bool AutoRunActive = false);
+ bool AutoRunActive = false,
+ bool HasCommandInput = false,
+ Gameplay.MovementInput CommandInput = default);
public interface IRuntimeMovementView
{
diff --git a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs
index 0720ba4b..ba29fda7 100644
--- a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs
+++ b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs
@@ -411,6 +411,7 @@ public sealed class PlayerMovementController
_animationRootMotionScratch = new();
private readonly AcDream.Core.Physics.Motion.MotionDeltaFrame
_positionManagerDeltaScratch = new();
+ private bool _externalMovementEventPending;
// ── R4-V5: the verbatim retail MoveToManager replaces B.6 auto-walk ──
// The B.6 DriveServerAutoWalk overlay (synthesized turn-first phase,
@@ -856,6 +857,35 @@ public sealed class PlayerMovementController
return true;
}
+ ///
+ /// Applies a command-originated retail posture through the same
+ /// CPhysicsObj movement boundary used by keyboard input. The next admitted
+ /// object turn emits the resulting raw movement state exactly once.
+ ///
+ public bool RequestPosture(uint motion)
+ {
+ if (motion is not (
+ MotionCommand.Ready
+ or MotionCommand.Crouch
+ or MotionCommand.Sitting
+ or MotionCommand.Sleeping))
+ {
+ return false;
+ }
+
+ TakeControlFromServer();
+ var parameters =
+ new AcDream.Core.Physics.Motion.MovementParameters();
+ if (DoMotionAtPhysicsObjectBoundary(motion, parameters)
+ != WeenieError.None)
+ {
+ return false;
+ }
+
+ _externalMovementEventPending = true;
+ return true;
+ }
+
public void SetCharacterSkills(int runSkill, int jumpSkill)
{
_weenie.SetSkills(runSkill, jumpSkill);
@@ -1455,8 +1485,12 @@ public sealed class PlayerMovementController
_motion.set_hold_run(input.Run, interrupt: false);
}
+ bool externallyRequestedMovementEvent =
+ _externalMovementEventPending;
+ _externalMovementEventPending = false;
bool motionEdgeFired = false;
- bool movementEventRequested = false;
+ bool movementEventRequested =
+ externallyRequestedMovementEvent;
{
bool userInputEdge = input.Run != _prevRunHeld
|| input.Forward != _prevForwardHeld
@@ -1511,7 +1545,7 @@ public sealed class PlayerMovementController
// an immediate SendMovementEvent. Mouse-origin turn updates below
// deliberately do not: CameraSet reports them on its separate
// half-second cadence.
- movementEventRequested = motionEdgeFired;
+ movementEventRequested |= motionEdgeFired;
// Turn channel. Retail CameraSet::Rotate (0x00458310) feeds mouse
// input through CommandInterpreter::MovePlayer as TurnLeft /
@@ -1937,6 +1971,14 @@ public sealed class PlayerMovementController
outForwardCmd = MotionCommand.WalkBackward;
outForwardSpeed = 1.0f;
}
+ else if (_motion.RawState.ForwardCommand is (
+ MotionCommand.Crouch
+ or MotionCommand.Sitting
+ or MotionCommand.Sleeping))
+ {
+ outForwardCmd = _motion.RawState.ForwardCommand;
+ outForwardSpeed = _motion.RawState.ForwardSpeed;
+ }
// Source the outbound axis from the canonical input-owned raw channel.
// CameraInstantMouseLook maps keyboard TurnLeft/TurnRight into this
@@ -1979,7 +2021,8 @@ public sealed class PlayerMovementController
|| outTurnCmd != _prevTurnCmd
|| !FloatsEqual(outForwardSpeed, _prevForwardSpeed)
|| runHold != _prevRunHold
- || motionEdgeFired;
+ || motionEdgeFired
+ || externallyRequestedMovementEvent;
bool mouseMovementEventDue = _mouseMovementEventPending
|| (_mouseMovementEventCandidate
diff --git a/src/AcDream.Runtime/Gameplay/RuntimeActionState.cs b/src/AcDream.Runtime/Gameplay/RuntimeActionState.cs
index cae73205..61ca4bb3 100644
--- a/src/AcDream.Runtime/Gameplay/RuntimeActionState.cs
+++ b/src/AcDream.Runtime/Gameplay/RuntimeActionState.cs
@@ -111,6 +111,8 @@ public sealed class RuntimeActionState : IDisposable
public IRuntimeActionView View { get; }
public bool IsDisposed => _disposed;
+ internal event Action? CombatChanged;
+
public RuntimeActionOwnershipSnapshot CaptureOwnership() => new(
_disposed,
_internalSubscriptionsAttached,
@@ -193,17 +195,26 @@ public sealed class RuntimeActionState : IDisposable
private void OnSelectionChanged(SelectionTransition _) =>
Interlocked.Increment(ref _selectionRevision);
- private void OnCombatModeChanged(CombatMode _) =>
+ private void OnCombatModeChanged(CombatMode _)
+ {
Interlocked.Increment(ref _combatRevision);
+ CombatChanged?.Invoke();
+ }
- private void OnHealthChanged(uint _, float __) =>
+ private void OnHealthChanged(uint _, float __)
+ {
Interlocked.Increment(ref _combatRevision);
+ CombatChanged?.Invoke();
+ }
private void OnInteractionChanged(InteractionModeTransition _) =>
Interlocked.Increment(ref _interactionRevision);
- private void OnCombatAttackChanged() =>
+ private void OnCombatAttackChanged()
+ {
Interlocked.Increment(ref _combatIntentRevision);
+ CombatChanged?.Invoke();
+ }
private void OnSpellCastChanged() =>
Interlocked.Increment(ref _magicIntentRevision);
diff --git a/src/AcDream.Runtime/Gameplay/RuntimeHostileTargetQuery.cs b/src/AcDream.Runtime/Gameplay/RuntimeHostileTargetQuery.cs
new file mode 100644
index 00000000..12a2e420
--- /dev/null
+++ b/src/AcDream.Runtime/Gameplay/RuntimeHostileTargetQuery.cs
@@ -0,0 +1,115 @@
+using System.Numerics;
+using AcDream.Core.Combat;
+using AcDream.Core.Items;
+using AcDream.Core.Net.Messages;
+using AcDream.Core.Physics;
+using AcDream.Runtime.Entities;
+
+namespace AcDream.Runtime.Gameplay;
+
+///
+/// Presentation-independent hostile-target query over the canonical Runtime
+/// directory and object table. Graphical hosts may retain their render-aware
+/// query, while direct hosts use this exact live state without constructing a
+/// second world model.
+///
+public static class RuntimeHostileTargetQuery
+{
+ public static uint? FindClosest(GameRuntime runtime)
+ {
+ ArgumentNullException.ThrowIfNull(runtime);
+ uint playerGuid = runtime.PlayerIdentity.ServerGuid;
+ if (playerGuid == 0u
+ || !runtime.EntityObjects.Entities.TryGetActive(
+ playerGuid,
+ out RuntimeEntityRecord playerRecord)
+ || playerRecord.Snapshot.Position is not { } playerPosition)
+ {
+ return null;
+ }
+
+ Vector3 playerWorld = AbsolutePosition(playerPosition);
+ ClientObjectTable objects = runtime.InventoryOwner.Objects;
+ ClientObject? player = objects.Get(playerGuid);
+ uint? closest = null;
+ float closestDistanceSquared = float.PositiveInfinity;
+ foreach (RuntimeEntityRecord record
+ in runtime.EntityObjects.Entities.ActiveRecords)
+ {
+ if (record.ServerGuid == playerGuid
+ || record.Snapshot.Position is not { } position
+ || (record.FinalPhysicsState
+ & (PhysicsStateFlags.Hidden
+ | PhysicsStateFlags.NoDraw)) != 0)
+ {
+ continue;
+ }
+ ClientObject? candidate = objects.Get(record.ServerGuid);
+ if (!CombatTargetPolicy.IsHostileMonster(
+ playerGuid,
+ player,
+ candidate))
+ {
+ continue;
+ }
+ if (runtime.ActionOwner.Combat.HasHealth(record.ServerGuid)
+ && runtime.ActionOwner.Combat.GetHealthPercent(
+ record.ServerGuid) <= 0f)
+ {
+ continue;
+ }
+
+ float distanceSquared = Vector3.DistanceSquared(
+ playerWorld,
+ AbsolutePosition(position));
+ if (distanceSquared >= closestDistanceSquared)
+ continue;
+ closestDistanceSquared = distanceSquared;
+ closest = record.ServerGuid;
+ }
+ return closest;
+ }
+
+ public static bool IsHostile(
+ GameRuntime runtime,
+ uint objectId)
+ {
+ ArgumentNullException.ThrowIfNull(runtime);
+ uint playerGuid = runtime.PlayerIdentity.ServerGuid;
+ if (objectId == 0u
+ || playerGuid == 0u
+ || !runtime.EntityObjects.Entities.TryGetActive(
+ objectId,
+ out RuntimeEntityRecord record)
+ || (record.FinalPhysicsState
+ & (PhysicsStateFlags.Hidden
+ | PhysicsStateFlags.NoDraw)) != 0)
+ {
+ return false;
+ }
+ if (runtime.ActionOwner.Combat.HasHealth(objectId)
+ && runtime.ActionOwner.Combat.GetHealthPercent(objectId) <= 0f)
+ {
+ return false;
+ }
+
+ ClientObjectTable objects = runtime.InventoryOwner.Objects;
+ return CombatTargetPolicy.IsHostileMonster(
+ playerGuid,
+ objects.Get(playerGuid),
+ objects.Get(objectId));
+ }
+
+ private static Vector3 AbsolutePosition(
+ CreateObject.ServerPosition position)
+ {
+ int landblockX =
+ (int)((position.LandblockId >> 24) & 0xFFu);
+ int landblockY =
+ (int)((position.LandblockId >> 16) & 0xFFu);
+ return new Vector3(
+ position.PositionX + landblockX * 192f,
+ position.PositionY + landblockY * 192f,
+ position.PositionZ);
+ }
+}
diff --git a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerFrameController.cs b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerFrameController.cs
new file mode 100644
index 00000000..738ad582
--- /dev/null
+++ b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerFrameController.cs
@@ -0,0 +1,203 @@
+using AcDream.Core.Physics;
+
+namespace AcDream.Runtime.Gameplay;
+
+public interface IRuntimeMovementInputSource
+{
+ MovementInput Capture();
+}
+
+public interface IRuntimeLocalPlayerFrameHost
+{
+ bool CanAdvancePlayer { get; }
+ PlayerMovementController? Controller { get; }
+ 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);
+}
+
+public readonly record struct RuntimeLocalPlayerPresentationFrame(
+ MovementResult Movement,
+ bool Hidden,
+ bool AdvancedBeforeNetwork);
+
+///
+/// Presentation-independent owner of retail's local-player object phase,
+/// inbound-network barrier, and post-network position phase. Graphical and
+/// direct hosts supply projections and input but execute this exact ordering.
+///
+public sealed class RuntimeLocalPlayerFrameController
+{
+ private readonly IRuntimeLocalPlayerFrameHost _host;
+ private readonly IRuntimeMovementInputSource _input;
+ private readonly Action? _publishMovement;
+ private AdvancedFrame? _advancedFrame;
+
+ private readonly record struct AdvancedFrame(
+ PlayerMovementController Controller,
+ MovementResult Movement,
+ bool ObjectAdvanced,
+ bool Hidden,
+ bool ObjectQuantumAdvanced);
+
+ public RuntimeLocalPlayerFrameController(
+ IRuntimeLocalPlayerFrameHost host,
+ IRuntimeMovementInputSource input,
+ Action? publishMovement = null)
+ {
+ _host = host ?? throw new ArgumentNullException(nameof(host));
+ _input = input ?? throw new ArgumentNullException(nameof(input));
+ _publishMovement = publishMovement;
+ }
+
+ public bool HiddenPartPoseDirty => _advancedFrame is
+ {
+ Hidden: true,
+ ObjectQuantumAdvanced: true,
+ };
+
+ public void AdvanceBeforeNetwork(float deltaSeconds)
+ {
+ _advancedFrame = null;
+ PlayerMovementController? controller = _host.Controller;
+ if (!_host.CanAdvancePlayer || controller is null)
+ return;
+
+ if (!float.IsFinite(deltaSeconds) || deltaSeconds <= 0f)
+ {
+ bool rejectedHidden = _host.IsHidden;
+ MovementResult rejected =
+ controller.CapturePresentationResult();
+ _host.Project(controller, rejected, rejectedHidden);
+ _advancedFrame = new AdvancedFrame(
+ controller,
+ rejected,
+ ObjectAdvanced: false,
+ Hidden: rejectedHidden,
+ ObjectQuantumAdvanced: false);
+ return;
+ }
+
+ controller.LocalEntityId = _host.ResolveLocalEntityId();
+
+ bool hidden = _host.IsHidden;
+ if (_host.ObjectClockDisposition
+ is RetailObjectClockDisposition.Suspend)
+ {
+ controller.SuspendObjectUpdate(deltaSeconds);
+ MovementResult paused =
+ controller.CapturePresentationResult();
+ _host.Project(controller, paused, hidden);
+ _advancedFrame = new AdvancedFrame(
+ controller,
+ paused,
+ ObjectAdvanced: false,
+ Hidden: hidden,
+ ObjectQuantumAdvanced: false);
+ return;
+ }
+
+ MovementResult movement = hidden
+ ? controller.TickHidden(
+ deltaSeconds,
+ _host.HandleTargeting)
+ : controller.Update(
+ deltaSeconds,
+ _input.Capture(),
+ _host.HandleTargeting);
+
+ _host.Project(controller, movement, hidden);
+ _host.SendPreNetwork(controller, movement, hidden);
+ _advancedFrame = new AdvancedFrame(
+ controller,
+ movement,
+ ObjectAdvanced: true,
+ Hidden: hidden,
+ ObjectQuantumAdvanced:
+ controller.AdvancedObjectQuantumLastTick);
+ }
+
+ public void RunPostNetworkCommandPhase()
+ {
+ PlayerMovementController? controller = _host.Controller;
+ if (!_host.CanAdvancePlayer || controller is null)
+ return;
+
+ bool hidden = _host.IsHidden;
+ if (_advancedFrame is { } advanced
+ && ReferenceEquals(advanced.Controller, controller))
+ {
+ MovementResult movement = RefreshSpatialFields(
+ advanced.Movement,
+ controller);
+ _host.Project(controller, movement, hidden);
+ _advancedFrame = new AdvancedFrame(
+ controller,
+ movement,
+ advanced.ObjectAdvanced,
+ advanced.Hidden,
+ advanced.ObjectQuantumAdvanced);
+
+ if (!advanced.ObjectAdvanced)
+ return;
+ }
+
+ _host.SendPostNetwork(controller, hidden);
+ _publishMovement?.Invoke();
+ }
+
+ public bool TryGetPresentationAfterNetwork(
+ out RuntimeLocalPlayerPresentationFrame frame)
+ {
+ frame = default;
+ PlayerMovementController? controller = _host.Controller;
+ if (!_host.CanAdvancePlayer || controller is null)
+ return false;
+
+ bool hidden = _host.IsHidden;
+ if (_advancedFrame is { } advanced
+ && ReferenceEquals(advanced.Controller, controller))
+ {
+ MovementResult movement = RefreshSpatialFields(
+ advanced.Movement,
+ controller);
+ frame = new RuntimeLocalPlayerPresentationFrame(
+ movement,
+ hidden,
+ AdvancedBeforeNetwork: advanced.ObjectAdvanced);
+ return true;
+ }
+
+ MovementResult initial =
+ controller.CapturePresentationResult();
+ _host.Project(controller, initial, hidden);
+ frame = new RuntimeLocalPlayerPresentationFrame(
+ initial,
+ hidden,
+ AdvancedBeforeNetwork: false);
+ return true;
+ }
+
+ private static MovementResult RefreshSpatialFields(
+ MovementResult movement,
+ PlayerMovementController controller) =>
+ movement with
+ {
+ Position = controller.Position,
+ RenderPosition = controller.RenderPosition,
+ CellId = controller.CellId,
+ IsOnGround = controller.CanSendPositionEvent,
+ };
+}
diff --git a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs
index c7e59f2d..c173b93d 100644
--- a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs
+++ b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs
@@ -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;
}
+ ///
+ /// 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.
+ ///
+ 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;
+ }
+
+ ///
+ /// 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.
+ ///
+ 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;
}
diff --git a/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs b/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs
index 1cb12b18..3b04be34 100644
--- a/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs
+++ b/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs
@@ -73,6 +73,7 @@ public readonly record struct RuntimeCollisionAcknowledgement(
///
public sealed class RuntimePhysicsState : IDisposable
{
+ private readonly TimeProvider _timeProvider;
private readonly Dictionary
_spatialRoots = new();
private readonly Dictionary
@@ -88,9 +89,11 @@ public sealed class RuntimePhysicsState : IDisposable
internal RuntimePhysicsState(
RuntimeEntityDirectory entities,
- PhysicsDataCache? dataCache = null)
+ PhysicsDataCache? dataCache = null,
+ TimeProvider? timeProvider = null)
{
Entities = entities ?? throw new ArgumentNullException(nameof(entities));
+ _timeProvider = timeProvider ?? TimeProvider.System;
DataCache = dataCache ?? PhysicsDataCache.CreateProduction();
Engine = new PhysicsEngine
{
@@ -100,9 +103,11 @@ public sealed class RuntimePhysicsState : IDisposable
internal RuntimePhysicsState(
RuntimeEntityDirectory entities,
- PhysicsEngine engine)
+ PhysicsEngine engine,
+ TimeProvider? timeProvider = null)
{
Entities = entities ?? throw new ArgumentNullException(nameof(entities));
+ _timeProvider = timeProvider ?? TimeProvider.System;
Engine = engine ?? throw new ArgumentNullException(nameof(engine));
DataCache = engine.DataCache ?? PhysicsDataCache.CreateProduction();
Engine.DataCache = DataCache;
@@ -114,6 +119,9 @@ public sealed class RuntimePhysicsState : IDisposable
public int SpatialRootCount => _spatialRoots.Count;
public int SpatialRemoteCount => _spatialRemotes.Count;
public int SpatialProjectileCount => _spatialProjectiles.Count;
+ internal double UtcNowSeconds =>
+ (_timeProvider.GetUtcNow() - DateTimeOffset.UnixEpoch)
+ .TotalSeconds;
public RuntimePhysicsOwnershipSnapshot CaptureOwnership() =>
new(
diff --git a/src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs b/src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs
index bc00468e..6401c918 100644
--- a/src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs
+++ b/src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs
@@ -107,7 +107,7 @@ internal sealed class RuntimeRemotePhysicsUpdater
// -> UpdatePhysicsInternal (FUN_00515020 / FUN_00513730 / FUN_005111D0).
// The bare block scopes this update's locals (formerly the else body).
{
- double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
+ double nowSec = _physics.UtcNowSeconds;
// Retail CPhysicsObj::UpdatePositionInternal @ 0x00512C30 scales
// the CSequence root displacement by m_scale only while the body
diff --git a/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs b/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs
index 0827fe75..f4daf6ea 100644
--- a/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs
+++ b/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs
@@ -1,4 +1,11 @@
+using AcDream.Core.Chat;
+using AcDream.Core.Combat;
+using AcDream.Core.Items;
using AcDream.Core.Net;
+using AcDream.Core.Net.Messages;
+using AcDream.Core.Selection;
+using AcDream.Core.Spells;
+using AcDream.Runtime.Entities;
using AcDream.Runtime.Gameplay;
namespace AcDream.Runtime.Session;
@@ -20,7 +27,8 @@ public sealed class DirectGameRuntimeCommandAdapter
IRuntimeInventoryStateCommands,
IRuntimeSpellbookCommands,
IRuntimeCharacterCommands,
- IRuntimeSocialCommands
+ IRuntimeSocialCommands,
+ IRuntimeInteractionTransport
{
private sealed class CommandRoute(
DirectGameRuntimeCommandAdapter owner,
@@ -139,9 +147,13 @@ public sealed class DirectGameRuntimeCommandAdapter
session!.SendTell(command.TargetName, command.Text);
break;
default:
- return EmitUnsupported(
- RuntimeCommandDomain.Chat,
- (int)command.Channel);
+ if (!TrySendChannel(session!, command.Channel, command.Text))
+ {
+ return EmitUnsupported(
+ RuntimeCommandDomain.Chat,
+ (int)command.Channel);
+ }
+ break;
}
_runtime.EventSink.EmitCommand(
@@ -190,167 +202,829 @@ public sealed class DirectGameRuntimeCommandAdapter
public RuntimeCommandResult Execute(
RuntimeGenerationToken expectedGeneration,
- RuntimeSelectionCommand command) =>
- Unsupported(
- expectedGeneration,
+ RuntimeSelectionCommand command)
+ {
+ RuntimeCommandStatus gate =
+ Validate(expectedGeneration, out WorldSession? session);
+ if (gate != RuntimeCommandStatus.Accepted)
+ return Result(gate);
+
+ RuntimeCommandStatus status = RuntimeCommandStatus.Accepted;
+ SelectionState selection = _runtime.ActionOwner.Selection;
+ switch (command)
+ {
+ case RuntimeSelectionCommand.SelectClosestHostile:
+ {
+ uint? closest =
+ RuntimeHostileTargetQuery.FindClosest(_runtime);
+ if (closest is { } objectId)
+ {
+ selection.Select(
+ objectId,
+ SelectionChangeSource.Keyboard);
+ }
+ else
+ {
+ selection.Clear(
+ SelectionChangeSource.Keyboard);
+ }
+ break;
+ }
+ case RuntimeSelectionCommand.SelectPrevious:
+ selection.SelectPrevious();
+ break;
+ case RuntimeSelectionCommand.ExamineSelected:
+ if (selection.SelectedObjectId is { } appraisalId)
+ {
+ _runtime.ActionOwner.Transactions.TryRequestAppraisal(
+ appraisalId,
+ session!.SendAppraise);
+ }
+ else
+ {
+ _runtime.ActionOwner.Interaction.EnterExamine();
+ }
+ break;
+ case RuntimeSelectionCommand.UseSelected:
+ status = UseSelected(session!);
+ break;
+ case RuntimeSelectionCommand.PickUpSelected:
+ status = PickUpSelected();
+ break;
+ default:
+ status = RuntimeCommandStatus.Unsupported;
+ break;
+ }
+
+ uint selected = selection.SelectedObjectId ?? 0u;
+ return EmitResult(
RuntimeCommandDomain.Selection,
- (int)command);
+ (int)command,
+ status,
+ selected);
+ }
+
+ public RuntimeCommandResult SelectObject(
+ RuntimeGenerationToken expectedGeneration,
+ uint objectId)
+ {
+ RuntimeCommandStatus gate =
+ Validate(expectedGeneration, out _);
+ if (gate != RuntimeCommandStatus.Accepted)
+ return Result(gate);
+
+ RuntimeCommandStatus status =
+ objectId != 0u
+ && _runtime.InventoryOwner.Objects.Get(objectId) is not null
+ ? RuntimeCommandStatus.Accepted
+ : RuntimeCommandStatus.Rejected;
+ if (status == RuntimeCommandStatus.Accepted)
+ {
+ _runtime.ActionOwner.Selection.Select(
+ objectId,
+ SelectionChangeSource.Plugin);
+ }
+ return EmitResult(
+ RuntimeCommandDomain.Selection,
+ operation: 0x100,
+ status,
+ objectId);
+ }
+
+ public RuntimeCommandResult Clear(
+ RuntimeGenerationToken expectedGeneration)
+ {
+ RuntimeCommandStatus gate =
+ Validate(expectedGeneration, out _);
+ if (gate != RuntimeCommandStatus.Accepted)
+ return Result(gate);
+ _runtime.ActionOwner.Selection.Clear(
+ SelectionChangeSource.Plugin);
+ return EmitResult(
+ RuntimeCommandDomain.Selection,
+ operation: 0x101,
+ RuntimeCommandStatus.Accepted);
+ }
public RuntimeCommandResult Execute(
RuntimeGenerationToken expectedGeneration,
- RuntimeCombatCommand command) =>
- Unsupported(
- expectedGeneration,
+ RuntimeCombatCommand command)
+ {
+ RuntimeCommandStatus gate =
+ Validate(expectedGeneration, out _);
+ if (gate != RuntimeCommandStatus.Accepted)
+ return Result(gate);
+
+ RuntimeCommandStatus status;
+ if (command == RuntimeCombatCommand.ToggleMode)
+ {
+ RuntimeCombatModeRequestResult result =
+ _runtime.ActionOwner.CombatMode.Toggle();
+ status = result.Status switch
+ {
+ RuntimeCombatModeRequestStatus.Sent =>
+ RuntimeCommandStatus.Accepted,
+ RuntimeCombatModeRequestStatus.Inactive =>
+ RuntimeCommandStatus.Inactive,
+ _ => RuntimeCommandStatus.Rejected,
+ };
+ }
+ else
+ {
+ status = RuntimeCommandStatus.Unsupported;
+ }
+ return EmitResult(
RuntimeCommandDomain.Combat,
- (int)command);
+ (int)command,
+ status);
+ }
public RuntimeCommandResult ExecuteAttack(
RuntimeGenerationToken expectedGeneration,
- in RuntimeCombatAttackInput command) =>
- Unsupported(
- expectedGeneration,
+ in RuntimeCombatAttackInput command)
+ {
+ RuntimeCommandStatus gate =
+ Validate(expectedGeneration, out _);
+ if (gate != RuntimeCommandStatus.Accepted)
+ return Result(gate);
+ RuntimeCommandStatus status =
+ _runtime.ActionOwner.CombatAttack.HandleCommand(command)
+ ? RuntimeCommandStatus.Accepted
+ : RuntimeCommandStatus.Unsupported;
+ return EmitResult(
RuntimeCommandDomain.Combat,
- (int)command.Command);
+ 0x100 + (int)command.Command,
+ status);
+ }
public RuntimeCommandResult Execute(
RuntimeGenerationToken expectedGeneration,
- in RuntimeMagicCommand command) =>
- Unsupported(
- expectedGeneration,
+ in RuntimeMagicCommand command)
+ {
+ RuntimeCommandStatus gate =
+ Validate(expectedGeneration, out _);
+ if (gate != RuntimeCommandStatus.Accepted)
+ return Result(gate);
+ CastRequestResult cast =
+ _runtime.ActionOwner.SpellCast.Cast(command.SpellId);
+ RuntimeCommandStatus status = cast == CastRequestResult.Sent
+ ? RuntimeCommandStatus.Accepted
+ : RuntimeCommandStatus.Rejected;
+ return EmitResult(
RuntimeCommandDomain.Magic,
- operation: 0,
+ (int)cast,
+ status,
command.SpellId);
+ }
public RuntimeCommandResult Execute(
RuntimeGenerationToken expectedGeneration,
- RuntimeMovementCommand command) =>
- Unsupported(
- expectedGeneration,
+ RuntimeMovementCommand command)
+ {
+ RuntimeCommandStatus gate =
+ Validate(expectedGeneration, out _);
+ if (gate != RuntimeCommandStatus.Accepted)
+ return Result(gate);
+ RuntimeCommandStatus status =
+ _runtime.MovementOwner.Execute(command)
+ ? RuntimeCommandStatus.Accepted
+ : RuntimeCommandStatus.Unsupported;
+ return EmitResult(
RuntimeCommandDomain.Movement,
- (int)command);
+ (int)command,
+ status);
+ }
+
+ public RuntimeCommandResult SetIntent(
+ RuntimeGenerationToken expectedGeneration,
+ in MovementInput input)
+ {
+ RuntimeCommandStatus gate =
+ Validate(expectedGeneration, out _);
+ if (gate != RuntimeCommandStatus.Accepted)
+ return Result(gate);
+ _runtime.MovementOwner.SetCommandInput(input);
+ return EmitResult(
+ RuntimeCommandDomain.Movement,
+ operation: 0x100,
+ RuntimeCommandStatus.Accepted);
+ }
+
+ public RuntimeCommandResult ClearIntent(
+ RuntimeGenerationToken expectedGeneration)
+ {
+ RuntimeCommandStatus gate =
+ Validate(expectedGeneration, out _);
+ if (gate != RuntimeCommandStatus.Accepted)
+ return Result(gate);
+ _runtime.MovementOwner.ClearCommandInput();
+ return EmitResult(
+ RuntimeCommandDomain.Movement,
+ operation: 0x101,
+ RuntimeCommandStatus.Accepted);
+ }
public RuntimeCommandResult AddShortcut(
RuntimeGenerationToken expectedGeneration,
- in RuntimeShortcutCommand command) =>
- Unsupported(
- expectedGeneration,
+ in RuntimeShortcutCommand command)
+ {
+ RuntimeCommandStatus gate =
+ Validate(expectedGeneration, out WorldSession? session);
+ if (gate != RuntimeCommandStatus.Accepted)
+ return Result(gate);
+ var entry = new ShortcutEntry(
+ command.Index,
+ command.ObjectId,
+ command.SpellId);
+ RuntimeCommandStatus status =
+ _runtime.InventoryOwner.TryAddShortcut(
+ entry,
+ () => session!.SendAddShortcut(entry))
+ ? RuntimeCommandStatus.Accepted
+ : RuntimeCommandStatus.Rejected;
+ return EmitResult(
RuntimeCommandDomain.InventoryState,
operation: 0,
+ status,
command.ObjectId);
+ }
public RuntimeCommandResult RemoveShortcut(
RuntimeGenerationToken expectedGeneration,
- int index) =>
- Unsupported(
- expectedGeneration,
+ int index)
+ {
+ RuntimeCommandStatus gate =
+ Validate(expectedGeneration, out WorldSession? session);
+ if (gate != RuntimeCommandStatus.Accepted)
+ return Result(gate);
+ RuntimeCommandStatus status =
+ _runtime.InventoryOwner.TryRemoveShortcut(
+ index,
+ () => session!.SendRemoveShortcut((uint)index))
+ ? RuntimeCommandStatus.Accepted
+ : RuntimeCommandStatus.Rejected;
+ return EmitResult(
RuntimeCommandDomain.InventoryState,
- operation: 1);
+ operation: 1,
+ status);
+ }
public RuntimeCommandResult AddFavorite(
RuntimeGenerationToken expectedGeneration,
int tabIndex,
int position,
- uint spellId) =>
- Unsupported(
- expectedGeneration,
+ uint spellId)
+ {
+ RuntimeCommandStatus gate =
+ Validate(expectedGeneration, out WorldSession? session);
+ if (gate != RuntimeCommandStatus.Accepted)
+ return Result(gate);
+ RuntimeCommandStatus status =
+ _runtime.CharacterOwner.TryAddFavorite(
+ tabIndex,
+ position,
+ spellId,
+ () => session!.SendAddSpellFavorite(
+ spellId,
+ position,
+ tabIndex))
+ ? RuntimeCommandStatus.Accepted
+ : RuntimeCommandStatus.Rejected;
+ return EmitResult(
RuntimeCommandDomain.Spellbook,
operation: 0,
+ status,
spellId);
+ }
public RuntimeCommandResult RemoveFavorite(
RuntimeGenerationToken expectedGeneration,
int tabIndex,
- uint spellId) =>
- Unsupported(
- expectedGeneration,
+ uint spellId)
+ {
+ RuntimeCommandStatus gate =
+ Validate(expectedGeneration, out WorldSession? session);
+ if (gate != RuntimeCommandStatus.Accepted)
+ return Result(gate);
+ RuntimeCommandStatus status =
+ _runtime.CharacterOwner.TryRemoveFavorite(
+ tabIndex,
+ spellId,
+ () => session!.SendRemoveSpellFavorite(
+ spellId,
+ tabIndex))
+ ? RuntimeCommandStatus.Accepted
+ : RuntimeCommandStatus.Rejected;
+ return EmitResult(
RuntimeCommandDomain.Spellbook,
operation: 1,
+ status,
spellId);
+ }
public RuntimeCommandResult SetFilter(
RuntimeGenerationToken expectedGeneration,
- uint filters) =>
- Unsupported(
- expectedGeneration,
+ uint filters)
+ {
+ RuntimeCommandStatus gate =
+ Validate(expectedGeneration, out WorldSession? session);
+ if (gate != RuntimeCommandStatus.Accepted)
+ return Result(gate);
+ _runtime.CharacterOwner.SetSpellbookFilter(
+ filters,
+ () => session!.SendSpellbookFilter(filters));
+ return EmitResult(
RuntimeCommandDomain.Spellbook,
- operation: 2);
+ operation: 2,
+ RuntimeCommandStatus.Accepted);
+ }
public RuntimeCommandResult ForgetSpell(
RuntimeGenerationToken expectedGeneration,
- uint spellId) =>
- Unsupported(
- expectedGeneration,
+ uint spellId)
+ {
+ RuntimeCommandStatus gate =
+ Validate(expectedGeneration, out WorldSession? session);
+ if (gate != RuntimeCommandStatus.Accepted)
+ return Result(gate);
+ RuntimeCommandStatus status = spellId == 0u
+ ? RuntimeCommandStatus.Rejected
+ : RuntimeCommandStatus.Accepted;
+ if (status == RuntimeCommandStatus.Accepted)
+ session!.SendRemoveSpell(spellId);
+ return EmitResult(
RuntimeCommandDomain.Spellbook,
operation: 3,
+ status,
spellId);
+ }
public RuntimeCommandResult SetDesiredComponent(
RuntimeGenerationToken expectedGeneration,
uint componentId,
- uint amount) =>
- Unsupported(
- expectedGeneration,
+ uint amount)
+ {
+ RuntimeCommandStatus gate =
+ Validate(expectedGeneration, out WorldSession? session);
+ if (gate != RuntimeCommandStatus.Accepted)
+ return Result(gate);
+ RuntimeCommandStatus status =
+ _runtime.CharacterOwner.TrySetDesiredComponent(
+ componentId,
+ amount,
+ () => session!.SendSetDesiredComponentLevel(
+ componentId,
+ amount))
+ ? RuntimeCommandStatus.Accepted
+ : RuntimeCommandStatus.Rejected;
+ return EmitResult(
RuntimeCommandDomain.Spellbook,
operation: 4,
+ status,
componentId);
+ }
public RuntimeCommandResult ClearDesiredComponents(
- RuntimeGenerationToken expectedGeneration) =>
- Unsupported(
- expectedGeneration,
+ RuntimeGenerationToken expectedGeneration)
+ {
+ RuntimeCommandStatus gate =
+ Validate(expectedGeneration, out WorldSession? session);
+ if (gate != RuntimeCommandStatus.Accepted)
+ return Result(gate);
+ _runtime.CharacterOwner.ClearDesiredComponents(
+ session!.SendClearDesiredComponents);
+ return EmitResult(
RuntimeCommandDomain.Spellbook,
- operation: 5);
+ operation: 5,
+ RuntimeCommandStatus.Accepted);
+ }
public RuntimeCommandResult Advance(
RuntimeGenerationToken expectedGeneration,
- in RuntimeAdvancementCommand command) =>
- Unsupported(
- expectedGeneration,
+ in RuntimeAdvancementCommand command)
+ {
+ RuntimeCommandStatus gate =
+ Validate(expectedGeneration, out WorldSession? session);
+ if (gate != RuntimeCommandStatus.Accepted)
+ return Result(gate);
+ RuntimeCommandStatus status = RuntimeCommandStatus.Accepted;
+ if (command.StatId == 0u || command.Cost == 0u)
+ {
+ status = RuntimeCommandStatus.Rejected;
+ }
+ else
+ {
+ switch (command.Kind)
+ {
+ case RuntimeAdvancementKind.Attribute:
+ session!.SendRaiseAttribute(
+ command.StatId,
+ command.Cost);
+ break;
+ case RuntimeAdvancementKind.Vital:
+ session!.SendRaiseVital(
+ command.StatId,
+ command.Cost);
+ break;
+ case RuntimeAdvancementKind.Skill:
+ session!.SendRaiseSkill(
+ command.StatId,
+ command.Cost);
+ break;
+ case RuntimeAdvancementKind.TrainSkill
+ when command.Cost <= uint.MaxValue:
+ session!.SendTrainSkill(
+ command.StatId,
+ (uint)command.Cost);
+ break;
+ default:
+ status = RuntimeCommandStatus.Rejected;
+ break;
+ }
+ }
+ return EmitResult(
RuntimeCommandDomain.Character,
(int)command.Kind,
+ status,
command.StatId);
+ }
public RuntimeCommandResult SetOptions1(
RuntimeGenerationToken expectedGeneration,
- uint options) =>
- Unsupported(
- expectedGeneration,
- RuntimeCommandDomain.Character,
- operation: 4);
-
- public RuntimeCommandResult Execute(
- RuntimeGenerationToken expectedGeneration,
- in RuntimeFriendCommand command) =>
- Unsupported(
- expectedGeneration,
- RuntimeCommandDomain.Social,
- (int)command.Kind,
- command.CharacterId);
-
- public RuntimeCommandResult Execute(
- RuntimeGenerationToken expectedGeneration,
- in RuntimeSquelchCommand command) =>
- Unsupported(
- expectedGeneration,
- RuntimeCommandDomain.Social,
- (int)command.Scope,
- command.CharacterId);
-
- private RuntimeCommandResult Unsupported(
- RuntimeGenerationToken expectedGeneration,
- RuntimeCommandDomain domain,
- int operation,
- uint primaryObjectId = 0u)
+ uint options)
{
RuntimeCommandStatus gate =
- Validate(expectedGeneration, out _);
- return gate == RuntimeCommandStatus.Accepted
- ? EmitUnsupported(
- domain,
- operation,
- RuntimeCommandStatus.Unsupported,
- primaryObjectId)
- : Result(gate);
+ Validate(expectedGeneration, out WorldSession? session);
+ if (gate != RuntimeCommandStatus.Accepted)
+ return Result(gate);
+ session!.SendSetCharacterOptions(options);
+ return EmitResult(
+ RuntimeCommandDomain.Character,
+ operation: 4,
+ RuntimeCommandStatus.Accepted);
+ }
+
+ public RuntimeCommandResult Execute(
+ RuntimeGenerationToken expectedGeneration,
+ in RuntimeFriendCommand command)
+ {
+ RuntimeCommandStatus gate =
+ Validate(expectedGeneration, out WorldSession? session);
+ if (gate != RuntimeCommandStatus.Accepted)
+ return Result(gate);
+ RuntimeCommandStatus status = RuntimeCommandStatus.Accepted;
+ switch (command.Kind)
+ {
+ case RuntimeFriendCommandKind.Add
+ when !string.IsNullOrWhiteSpace(command.Name):
+ session!.SendAddFriend(command.Name);
+ break;
+ case RuntimeFriendCommandKind.Remove
+ when command.CharacterId != 0u:
+ session!.SendRemoveFriend(command.CharacterId);
+ break;
+ case RuntimeFriendCommandKind.Clear:
+ session!.SendClearFriends();
+ break;
+ case RuntimeFriendCommandKind.RequestLegacyList:
+ session!.SendLegacyFriendsListRequest();
+ break;
+ default:
+ status = RuntimeCommandStatus.Rejected;
+ break;
+ }
+ return EmitResult(
+ RuntimeCommandDomain.Social,
+ (int)command.Kind,
+ status,
+ command.CharacterId,
+ command.Name);
+ }
+
+ public RuntimeCommandResult Execute(
+ RuntimeGenerationToken expectedGeneration,
+ in RuntimeSquelchCommand command)
+ {
+ RuntimeCommandStatus gate =
+ Validate(expectedGeneration, out WorldSession? session);
+ if (gate != RuntimeCommandStatus.Accepted)
+ return Result(gate);
+ RuntimeCommandStatus status = RuntimeCommandStatus.Accepted;
+ switch (command.Scope)
+ {
+ case RuntimeSquelchScope.Character
+ when command.CharacterId != 0u
+ && !string.IsNullOrWhiteSpace(command.Name):
+ session!.SendModifyCharacterSquelch(
+ command.Add,
+ command.CharacterId,
+ command.Name,
+ command.MessageType);
+ break;
+ case RuntimeSquelchScope.Account
+ when !string.IsNullOrWhiteSpace(command.Name):
+ session!.SendModifyAccountSquelch(
+ command.Add,
+ command.Name);
+ break;
+ case RuntimeSquelchScope.Global:
+ session!.SendModifyGlobalSquelch(
+ command.Add,
+ command.MessageType);
+ break;
+ default:
+ status = RuntimeCommandStatus.Rejected;
+ break;
+ }
+ return EmitResult(
+ RuntimeCommandDomain.Social,
+ 4 + (int)command.Scope,
+ status,
+ command.CharacterId,
+ command.Name);
+ }
+
+ bool IRuntimeInteractionTransport.IsInWorld
+ {
+ get
+ {
+ lock (_gate)
+ {
+ return _route is not null
+ && _session is not null
+ && _routeGeneration == _runtime.Generation
+ && _runtime.Session.IsInWorld;
+ }
+ }
+ }
+
+ bool IRuntimeInteractionTransport.TrySendUse(
+ uint serverGuid,
+ out uint sequence)
+ {
+ lock (_gate)
+ {
+ if (_route is null
+ || _session is null
+ || _routeGeneration != _runtime.Generation
+ || !_runtime.Session.IsInWorld)
+ {
+ sequence = 0u;
+ return false;
+ }
+
+ sequence = _session.NextGameActionSequence();
+ _session.SendGameAction(
+ InteractRequests.BuildUse(sequence, serverGuid));
+ return true;
+ }
+ }
+
+ bool IRuntimeInteractionTransport.TrySendPickup(
+ uint itemGuid,
+ uint destinationContainerId,
+ int placement,
+ out uint sequence)
+ {
+ lock (_gate)
+ {
+ if (_route is null
+ || _session is null
+ || _routeGeneration != _runtime.Generation
+ || !_runtime.Session.IsInWorld)
+ {
+ sequence = 0u;
+ return false;
+ }
+
+ sequence = _session.NextGameActionSequence();
+ _session.SendGameAction(
+ InteractRequests.BuildPickUp(
+ sequence,
+ itemGuid,
+ destinationContainerId,
+ placement));
+ return true;
+ }
+ }
+
+ private RuntimeCommandStatus UseSelected(WorldSession session)
+ {
+ if (_runtime.ActionOwner.Selection.SelectedObjectId
+ is not uint selected)
+ {
+ _runtime.ActionOwner.Interaction.EnterUse();
+ return RuntimeCommandStatus.Accepted;
+ }
+ if (_runtime.InventoryOwner.Objects.Get(selected)
+ is not { } item)
+ {
+ return RuntimeCommandStatus.Rejected;
+ }
+
+ long nowMs = checked((long)Math.Floor(
+ _runtime.Clock.SimulationTimeSeconds * 1000d));
+ if (!_runtime.ActionOwner.Transactions
+ .TryConsumeUseThrottle(nowMs))
+ {
+ return RuntimeCommandStatus.Accepted;
+ }
+
+ uint useability = item.Useability ?? ItemUseability.Undef;
+ if (ItemUseability.IsTargeted(useability))
+ {
+ uint targetId;
+ if (ItemUseability.AllowsSelfTarget(useability))
+ targetId = _runtime.PlayerIdentity.ServerGuid;
+ else if (ItemUseability.AllowsObjectSelfTarget(useability))
+ targetId = selected;
+ else
+ {
+ _runtime.ActionOwner.Interaction
+ .EnterUseItemOnTarget(selected);
+ return RuntimeCommandStatus.Accepted;
+ }
+
+ bool sent = _runtime.ActionOwner.Transactions
+ .TryDispatchTargetedUse(
+ selected,
+ targetId,
+ session.SendUseWithTarget,
+ incrementBusy: true);
+ return sent
+ ? RuntimeCommandStatus.Accepted
+ : RuntimeCommandStatus.Rejected;
+ }
+
+ bool ownedByPlayer = IsOwnedByPlayer(item);
+ ItemUseRequestReservation reservation;
+ try
+ {
+ reservation = _runtime.ActionOwner.Transactions
+ .BeginUseRequestReservation();
+ }
+ catch (InvalidOperationException)
+ {
+ return RuntimeCommandStatus.Rejected;
+ }
+
+ RuntimeInteractionDispatchResult dispatched =
+ _runtime.ActionOwner.Transactions.TryDispatchUse(
+ selected,
+ ownedByPlayer,
+ ownedByPlayer || ItemUseability.IsUseable(useability),
+ reservation,
+ this,
+ out _);
+ return dispatched == RuntimeInteractionDispatchResult.Dispatched
+ ? RuntimeCommandStatus.Accepted
+ : RuntimeCommandStatus.Rejected;
+ }
+
+ private RuntimeCommandStatus PickUpSelected()
+ {
+ if (_runtime.ActionOwner.Selection.SelectedObjectId
+ is not uint selected)
+ {
+ return RuntimeCommandStatus.Accepted;
+ }
+ ClientObject? item =
+ _runtime.InventoryOwner.Objects.Get(selected);
+ if (item is null
+ || (item.Type & ItemType.Creature) != 0)
+ {
+ return RuntimeCommandStatus.Rejected;
+ }
+
+ uint playerGuid = _runtime.PlayerIdentity.ServerGuid;
+ if (playerGuid == 0u)
+ return RuntimeCommandStatus.Inactive;
+ uint localEntityId =
+ _runtime.EntityObjects.Entities.TryGetActive(
+ selected,
+ out RuntimeEntityRecord record)
+ ? record.LocalEntityId ?? 0u
+ : 0u;
+ var pickup = new RuntimePendingPickup(
+ Token: 0u,
+ selected,
+ localEntityId,
+ playerGuid,
+ Placement: 0,
+ PendingPlacementToken: 0u,
+ ApproachToken: default);
+ return _runtime.ActionOwner.Transactions.TryDispatchPickup(
+ pickup,
+ this,
+ out _)
+ ? RuntimeCommandStatus.Accepted
+ : RuntimeCommandStatus.Rejected;
+ }
+
+ private bool IsOwnedByPlayer(ClientObject item)
+ {
+ uint playerGuid = _runtime.PlayerIdentity.ServerGuid;
+ if (playerGuid == 0u)
+ return false;
+ ClientObject current = item;
+ for (int depth = 0; depth < 16; depth++)
+ {
+ if (current.ObjectId == playerGuid
+ || current.WielderId == playerGuid
+ || current.ContainerId == playerGuid)
+ {
+ return true;
+ }
+ if (current.ContainerId == 0u
+ || _runtime.InventoryOwner.Objects.Get(
+ current.ContainerId) is not { } parent)
+ {
+ return false;
+ }
+ current = parent;
+ }
+ return false;
+ }
+
+ private bool TrySendChannel(
+ WorldSession session,
+ RuntimeChatChannel channel,
+ string text)
+ {
+ if (TryMapTurbine(
+ channel,
+ out ChatChannelKindLite turbineKind,
+ out TurbineChat.ChatType chatType))
+ {
+ TurbineChatState state =
+ _runtime.CommunicationOwner.TurbineChat;
+ uint roomId = state.RoomFor(turbineKind);
+ if (state.Enabled && roomId != 0u)
+ {
+ session.SendTurbineChatTo(
+ roomId,
+ (uint)chatType,
+ (uint)TurbineChat.DispatchType.SendToRoomById,
+ _runtime.PlayerIdentity.ServerGuid,
+ text,
+ state.NextContextId());
+ return true;
+ }
+ }
+
+ uint? legacyChannel = channel switch
+ {
+ RuntimeChatChannel.Fellowship => 0x00000800u,
+ RuntimeChatChannel.Allegiance => 0x02000000u,
+ RuntimeChatChannel.Vassals => 0x00001000u,
+ RuntimeChatChannel.Patron => 0x00002000u,
+ RuntimeChatChannel.Monarch => 0x00004000u,
+ RuntimeChatChannel.CoVassals => 0x01000000u,
+ _ => null,
+ };
+ if (legacyChannel is not { } channelId)
+ return false;
+ session.SendChannel(channelId, text);
+ return true;
+ }
+
+ private static bool TryMapTurbine(
+ RuntimeChatChannel channel,
+ out ChatChannelKindLite kind,
+ out TurbineChat.ChatType chatType)
+ {
+ (kind, chatType) = channel switch
+ {
+ RuntimeChatChannel.Allegiance => (
+ ChatChannelKindLite.Allegiance,
+ TurbineChat.ChatType.Allegiance),
+ RuntimeChatChannel.General => (
+ ChatChannelKindLite.General,
+ TurbineChat.ChatType.General),
+ RuntimeChatChannel.Trade => (
+ ChatChannelKindLite.Trade,
+ TurbineChat.ChatType.Trade),
+ RuntimeChatChannel.LookingForGroup => (
+ ChatChannelKindLite.Lfg,
+ TurbineChat.ChatType.Lfg),
+ RuntimeChatChannel.Roleplay => (
+ ChatChannelKindLite.Roleplay,
+ TurbineChat.ChatType.Roleplay),
+ RuntimeChatChannel.Society => (
+ ChatChannelKindLite.Society,
+ TurbineChat.ChatType.Society),
+ RuntimeChatChannel.Olthoi => (
+ ChatChannelKindLite.Olthoi,
+ TurbineChat.ChatType.Olthoi),
+ _ => default,
+ };
+ return channel is RuntimeChatChannel.Allegiance
+ or RuntimeChatChannel.General
+ or RuntimeChatChannel.Trade
+ or RuntimeChatChannel.LookingForGroup
+ or RuntimeChatChannel.Roleplay
+ or RuntimeChatChannel.Society
+ or RuntimeChatChannel.Olthoi;
}
private RuntimeCommandResult EmitUnsupported(
@@ -367,6 +1041,22 @@ public sealed class DirectGameRuntimeCommandAdapter
return Result(status, primaryObjectId);
}
+ private RuntimeCommandResult EmitResult(
+ RuntimeCommandDomain domain,
+ int operation,
+ RuntimeCommandStatus status,
+ uint primaryObjectId = 0u,
+ string? text = null)
+ {
+ _runtime.EventSink.EmitCommand(
+ domain,
+ operation,
+ status,
+ primaryObjectId,
+ text);
+ return Result(status, primaryObjectId);
+ }
+
private RuntimeCommandStatus Validate(
RuntimeGenerationToken expectedGeneration,
out WorldSession? session)
diff --git a/tests/AcDream.App.Tests/Input/RetailLocalPlayerFrameControllerTests.cs b/tests/AcDream.App.Tests/Input/RetailLocalPlayerFrameControllerTests.cs
index 01ce901c..67647629 100644
--- a/tests/AcDream.App.Tests/Input/RetailLocalPlayerFrameControllerTests.cs
+++ b/tests/AcDream.App.Tests/Input/RetailLocalPlayerFrameControllerTests.cs
@@ -270,6 +270,60 @@ public sealed class RetailLocalPlayerFrameControllerTests
Assert.Equal(10, projections);
}
+ [Fact]
+ public void GraphicalWrapperAndDirectRuntimeFrameProduceIdenticalTrace()
+ {
+ List Run(bool graphical)
+ {
+ PlayerMovementController controller = CreateController();
+ var trace = new List();
+ var host = new TestLocalPlayerFrameRuntime(
+ () => true,
+ () => controller,
+ () => 7u,
+ () => trace.Add("target"),
+ () => false,
+ (_, movement, hidden) =>
+ trace.Add(
+ $"project:{movement.CellId:X8}:"
+ + $"{movement.ForwardCommand:X8}:"
+ + $"{movement.ShouldSendMovementEvent}:"
+ + $"{hidden}"),
+ (_, movement, hidden) =>
+ trace.Add(
+ $"pre:{movement.ForwardCommand:X8}:"
+ + $"{movement.ShouldSendMovementEvent}:"
+ + $"{hidden}"),
+ (_, hidden) => trace.Add($"post:{hidden}"),
+ () => RetailObjectClockDisposition.Advance);
+ IMovementInputSource input = Input(
+ () => new MovementInput(
+ Forward: true,
+ Run: true));
+
+ if (graphical)
+ {
+ var frame = new RetailLocalPlayerFrameController(
+ host,
+ input);
+ frame.AdvanceBeforeNetwork(PhysicsBody.MaxQuantum);
+ frame.RunPostNetworkCommandPhase();
+ }
+ else
+ {
+ var frame = new RuntimeLocalPlayerFrameController(
+ host,
+ input);
+ frame.AdvanceBeforeNetwork(PhysicsBody.MaxQuantum);
+ frame.RunPostNetworkCommandPhase();
+ }
+
+ return trace;
+ }
+
+ Assert.Equal(Run(graphical: true), Run(graphical: false));
+ }
+
private static PlayerMovementController CreateController()
{
var engine = new PhysicsEngine();
diff --git a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs
index 476bcf3a..f201f112 100644
--- a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs
+++ b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs
@@ -1261,6 +1261,10 @@ public sealed class CurrentGameRuntimeAdapterTests
public void OnPortal(in RuntimePortalDelta delta)
{
}
+
+ public void OnCombat(in RuntimeCombatDelta delta)
+ {
+ }
}
private sealed class SelectionQuery(uint target) : IWorldSelectionQuery
diff --git a/tests/AcDream.App.Tests/Spells/RetailSpellTargetPolicyTests.cs b/tests/AcDream.App.Tests/Spells/RetailSpellTargetPolicyTests.cs
index f18ce13f..0dbe41e0 100644
--- a/tests/AcDream.App.Tests/Spells/RetailSpellTargetPolicyTests.cs
+++ b/tests/AcDream.App.Tests/Spells/RetailSpellTargetPolicyTests.cs
@@ -1,4 +1,3 @@
-using AcDream.App.Spells;
using AcDream.Core.Items;
using AcDream.Core.Spells;
diff --git a/tests/AcDream.Headless.Tests/HeadlessProcessSchedulerTests.cs b/tests/AcDream.Headless.Tests/HeadlessProcessSchedulerTests.cs
index eee8c675..05d0fd10 100644
--- a/tests/AcDream.Headless.Tests/HeadlessProcessSchedulerTests.cs
+++ b/tests/AcDream.Headless.Tests/HeadlessProcessSchedulerTests.cs
@@ -113,6 +113,33 @@ public sealed class HeadlessProcessSchedulerTests
Assert.True(session.Runtime.Session.IsInWorld);
}
+ [Fact]
+ public void IdleDispatchBeforeDeadlineAllocatesNothingAndDoesNotTick()
+ {
+ var time = new ManualTimeProvider();
+ var operations = new FixtureSessionOperations();
+ using HeadlessSessionHost session =
+ CreateSession("idle", time, operations);
+ Assert.Equal(
+ RuntimeSessionStartStatus.Connected,
+ session.Start().Status);
+ var scheduler = new HeadlessProcessScheduler([session], time);
+
+ for (int index = 0; index < 32; index++)
+ Assert.False(scheduler.DispatchDue(time.GetTimestamp()));
+ long before = GC.GetAllocatedBytesForCurrentThread();
+ for (int index = 0; index < 100_000; index++)
+ {
+ if (scheduler.DispatchDue(time.GetTimestamp()))
+ throw new InvalidOperationException("Idle session ticked.");
+ }
+ long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
+
+ Assert.Equal(0L, allocated);
+ Assert.Equal(0UL, session.Runtime.Clock.FrameNumber);
+ Assert.Equal(0L, scheduler.CaptureSnapshot().TurnCount);
+ }
+
[Fact]
public async Task ProcessHostRunsMultipleSessionsOnOneScheduler()
{
@@ -134,7 +161,7 @@ public sealed class HeadlessProcessSchedulerTests
using var host = new HeadlessProcessHost(
configuration,
HeadlessPathSet.Resolve(new HeadlessPathOverrides()),
- new StringReader(
+ new System.IO.StringReader(
"first-password"
+ Environment.NewLine
+ "second-password"
diff --git a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs
index 29cd5093..a4215e4d 100644
--- a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs
+++ b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs
@@ -1,12 +1,17 @@
+using System.Buffers.Binary;
using System.Net;
+using System.Numerics;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
+using AcDream.Core.Physics;
using AcDream.Headless.Configuration;
using AcDream.Headless.Credentials;
using AcDream.Headless.Diagnostics;
using AcDream.Headless.Hosting;
using AcDream.Headless.Platform;
using AcDream.Runtime;
+using AcDream.Runtime.Entities;
+using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Session;
namespace AcDream.Headless.Tests;
@@ -72,7 +77,8 @@ public sealed class HeadlessSessionHostTests
using var host = new HeadlessProcessHost(
configuration,
paths,
- new StringReader("process-password" + Environment.NewLine),
+ new System.IO.StringReader(
+ "process-password" + Environment.NewLine),
diagnostics,
operations);
using var cancellation = new CancellationTokenSource();
@@ -88,6 +94,71 @@ public sealed class HeadlessSessionHostTests
diagnostics.ToString());
}
+ [Fact]
+ public void DirectFrameUsesSharedRetailOrderAndMovementCadence()
+ {
+ var operations = new FixtureSessionOperations();
+ using var credential = new HeadlessCredentialSecret(
+ "fixture",
+ "password");
+ using var host = new HeadlessSessionHost(
+ Descriptor(),
+ credential,
+ new HeadlessDiagnosticWriter(TextWriter.Null),
+ operations);
+ Assert.Equal(
+ RuntimeSessionStartStatus.Connected,
+ host.Start().Status);
+ HydrateGroundedPlayer(host.Runtime);
+ var sent = new List<(byte[] Body, double Time)>();
+ var trace = new RuntimeTraceRecorder();
+ using IDisposable subscription =
+ host.Runtime.Subscribe(trace);
+ operations.Sessions[^1].GameActionCapture =
+ body => sent.Add((
+ body,
+ host.Runtime.Clock.SimulationTimeSeconds));
+
+ RuntimeCommandResult intent = host.Commands.Movement.SetIntent(
+ host.Runtime.Generation,
+ new MovementInput(Forward: true, Run: true));
+ host.Tick(0.015d);
+
+ Assert.True(intent.Accepted);
+ Assert.Contains(
+ trace.Entries,
+ static entry =>
+ entry.Kind == RuntimeTraceKind.Movement);
+ Assert.Equal(
+ [
+ MoveToState.MoveToStateAction,
+ AutonomousPosition.AutonomousPositionAction,
+ ],
+ sent.Select(static entry =>
+ ActionOpcode(entry.Body)).ToArray());
+
+ sent.Clear();
+ for (int index = 0; index < 70; index++)
+ host.Tick(0.015d);
+
+ (byte[] Body, double Time)[] positions = sent
+ .Where(static entry =>
+ ActionOpcode(entry.Body)
+ == AutonomousPosition.AutonomousPositionAction)
+ .ToArray();
+ Assert.InRange(positions.Length, 1, 2);
+ Assert.True(positions[^1].Time >= 1d);
+ if (positions.Length == 2)
+ {
+ Assert.True(
+ positions[1].Time - positions[0].Time >= 0.99d);
+ }
+ Assert.DoesNotContain(
+ sent,
+ entry => ActionOpcode(entry.Body)
+ == MoveToState.MoveToStateAction);
+ }
+
[Fact]
public void TeardownRetriesOnlyTheUnfinishedSuffix()
{
@@ -142,8 +213,109 @@ public sealed class HeadlessSessionHostTests
},
};
+ private static void HydrateGroundedPlayer(GameRuntime runtime)
+ {
+ const uint player = 0x50000002u;
+ PhysicsEngine engine = runtime.EntityObjects.Physics.Engine;
+ var heights = new byte[81];
+ Array.Fill(heights, (byte)50);
+ var heightTable = new float[256];
+ for (int index = 0; index < heightTable.Length; index++)
+ heightTable[index] = index;
+ engine.AddLandblock(
+ 0xA9B4FFFFu,
+ new TerrainSurface(heights, heightTable),
+ [],
+ [],
+ worldOffsetX: 0f,
+ worldOffsetY: 0f);
+
+ RuntimeEntityRecord record = runtime.EntityObjects
+ .RegisterEntity(Spawn(player))
+ .Canonical!;
+ Assert.True(runtime.EntityObjects.ApplyAcceptedSpawn(
+ record,
+ record.CreateIntegrationVersion,
+ record.Snapshot,
+ replaceGeneration: false));
+ var controller = new PlayerMovementController(engine);
+ controller.SetPosition(
+ new Vector3(96f, 97f, 50f),
+ 0xA9B40001u,
+ new Vector3(96f, 97f, 50f));
+ runtime.MovementOwner.Controller = controller;
+ }
+
+ private static WorldSession.EntitySpawn Spawn(uint guid)
+ {
+ var position = new CreateObject.ServerPosition(
+ 0xA9B40001u,
+ 96f,
+ 97f,
+ 50f,
+ 1f,
+ 0f,
+ 0f,
+ 0f);
+ var timestamps = new PhysicsTimestamps(
+ Position: 1,
+ Movement: 1,
+ State: 1,
+ Vector: 1,
+ Teleport: 0,
+ ServerControlledMove: 1,
+ ForcePosition: 0,
+ ObjDesc: 1,
+ Instance: 1);
+ var physics = new PhysicsSpawnData(
+ RawState: (uint)PhysicsStateFlags.ReportCollisions,
+ Position: position,
+ Movement: null,
+ AnimationFrame: null,
+ SetupTableId: 0x02000001u,
+ MotionTableId: null,
+ SoundTableId: null,
+ PhysicsScriptTableId: null,
+ Parent: null,
+ Children: null,
+ Scale: null,
+ Friction: null,
+ Elasticity: null,
+ Translucency: null,
+ Velocity: null,
+ Acceleration: null,
+ AngularVelocity: null,
+ DefaultScriptType: null,
+ DefaultScriptIntensity: null,
+ Timestamps: timestamps);
+ return new WorldSession.EntitySpawn(
+ guid,
+ position,
+ 0x02000001u,
+ [],
+ [],
+ [],
+ null,
+ null,
+ "Headless",
+ null,
+ null,
+ null,
+ PhysicsState: physics.RawState,
+ InstanceSequence: 1,
+ MovementSequence: 1,
+ ServerControlSequence: 1,
+ PositionSequence: 1,
+ Physics: physics);
+ }
+
+ private static uint ActionOpcode(byte[] body) =>
+ BinaryPrimitives.ReadUInt32LittleEndian(
+ body.AsSpan(8, sizeof(uint)));
+
private sealed class FixtureSessionOperations : ILiveSessionOperations
{
+ public List Sessions { get; } = [];
public int CreatedSessionCount { get; private set; }
public int DisposedSessionCount { get; private set; }
@@ -153,7 +325,9 @@ public sealed class HeadlessSessionHostTests
public WorldSession CreateSession(IPEndPoint endpoint)
{
CreatedSessionCount++;
- return new WorldSession(endpoint);
+ var session = new WorldSession(endpoint);
+ Sessions.Add(session);
+ return session;
}
public void Connect(
diff --git a/tests/AcDream.Runtime.Tests/GameRuntimeTests.cs b/tests/AcDream.Runtime.Tests/GameRuntimeTests.cs
index 42ad270e..9c7ce21b 100644
--- a/tests/AcDream.Runtime.Tests/GameRuntimeTests.cs
+++ b/tests/AcDream.Runtime.Tests/GameRuntimeTests.cs
@@ -255,6 +255,7 @@ public sealed class GameRuntimeTests
public void OnChat(in RuntimeChatDelta delta) => Chat.Add(delta);
public void OnMovement(in RuntimeMovementDelta delta) { }
public void OnPortal(in RuntimePortalDelta delta) { }
+ public void OnCombat(in RuntimeCombatDelta delta) { }
}
private sealed class InjectedConstructionFailure : Exception
diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeHostileTargetQueryTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeHostileTargetQueryTests.cs
new file mode 100644
index 00000000..dc6ac8b6
--- /dev/null
+++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeHostileTargetQueryTests.cs
@@ -0,0 +1,325 @@
+using AcDream.Core.Combat;
+using AcDream.Core.Items;
+using AcDream.Core.Net;
+using AcDream.Core.Net.Messages;
+using AcDream.Core.Physics;
+using AcDream.Core.Spells;
+using AcDream.Runtime.Entities;
+using AcDream.Runtime.Gameplay;
+
+namespace AcDream.Runtime.Tests.Gameplay;
+
+public sealed class RuntimeHostileTargetQueryTests
+{
+ private const uint Player = 0x50000001u;
+
+ [Fact]
+ public void FindClosest_UsesAbsoluteDerethCoordinatesAcrossLandblocks()
+ {
+ using GameRuntime runtime = Create();
+ runtime.PlayerIdentity.ServerGuid = Player;
+ Add(
+ runtime,
+ Player,
+ landblock: 0x01010001u,
+ x: 191f,
+ y: 100f,
+ PlayerObject(Player));
+ Add(
+ runtime,
+ 0x50000010u,
+ landblock: 0x02010001u,
+ x: 1f,
+ y: 100f,
+ Hostile(0x50000010u));
+ Add(
+ runtime,
+ 0x50000011u,
+ landblock: 0x01010001u,
+ x: 180f,
+ y: 100f,
+ Hostile(0x50000011u));
+
+ Assert.Equal(
+ 0x50000010u,
+ RuntimeHostileTargetQuery.FindClosest(runtime));
+ Assert.True(
+ RuntimeHostileTargetQuery.IsHostile(
+ runtime,
+ 0x50000010u));
+ }
+
+ [Fact]
+ public void Query_RejectsHiddenNoDrawDeadPlayersPetsNpcsAndNonCreatures()
+ {
+ using GameRuntime runtime = Create();
+ runtime.PlayerIdentity.ServerGuid = Player;
+ Add(runtime, Player, 0x01010001u, 10f, 10f, PlayerObject(Player));
+ Add(
+ runtime,
+ 0x50000010u,
+ 0x01010001u,
+ 11f,
+ 10f,
+ Hostile(0x50000010u),
+ PhysicsStateFlags.Hidden);
+ Add(
+ runtime,
+ 0x50000011u,
+ 0x01010001u,
+ 12f,
+ 10f,
+ Hostile(0x50000011u),
+ PhysicsStateFlags.NoDraw);
+ Add(
+ runtime,
+ 0x50000012u,
+ 0x01010001u,
+ 13f,
+ 10f,
+ Hostile(0x50000012u));
+ runtime.ActionOwner.Combat.OnUpdateHealth(0x50000012u, 0f);
+ Add(
+ runtime,
+ 0x50000013u,
+ 0x01010001u,
+ 14f,
+ 10f,
+ PlayerObject(0x50000013u));
+ Add(
+ runtime,
+ 0x50000014u,
+ 0x01010001u,
+ 15f,
+ 10f,
+ Hostile(0x50000014u, petOwner: Player));
+ Add(
+ runtime,
+ 0x50000015u,
+ 0x01010001u,
+ 16f,
+ 10f,
+ new ClientObject
+ {
+ ObjectId = 0x50000015u,
+ Type = ItemType.Misc,
+ PublicWeenieBitfield =
+ SelectedObjectHealthPolicy.BfAttackable,
+ });
+ Add(
+ runtime,
+ 0x50000016u,
+ 0x01010001u,
+ 17f,
+ 10f,
+ new ClientObject
+ {
+ ObjectId = 0x50000016u,
+ Type = ItemType.Creature,
+ });
+ Add(
+ runtime,
+ 0x50000017u,
+ 0x01010001u,
+ 18f,
+ 10f,
+ Hostile(0x50000017u));
+
+ Assert.Equal(
+ 0x50000017u,
+ RuntimeHostileTargetQuery.FindClosest(runtime));
+ Assert.False(RuntimeHostileTargetQuery.IsHostile(runtime, 0u));
+ Assert.False(
+ RuntimeHostileTargetQuery.IsHostile(
+ runtime,
+ 0x50000010u));
+ Assert.False(
+ RuntimeHostileTargetQuery.IsHostile(
+ runtime,
+ 0x50000012u));
+ Assert.False(
+ RuntimeHostileTargetQuery.IsHostile(
+ runtime,
+ 0x50000014u));
+ Assert.True(
+ RuntimeHostileTargetQuery.IsHostile(
+ runtime,
+ 0x50000017u));
+ }
+
+ [Fact]
+ public void FindClosest_ReturnsNullWithoutPlayerPositionOrHostile()
+ {
+ using GameRuntime runtime = Create();
+ runtime.PlayerIdentity.ServerGuid = Player;
+ runtime.InventoryOwner.Objects.AddOrUpdate(PlayerObject(Player));
+
+ Assert.Null(RuntimeHostileTargetQuery.FindClosest(runtime));
+
+ Add(runtime, Player, 0x01010001u, 10f, 10f, PlayerObject(Player));
+ Add(
+ runtime,
+ 0x50000010u,
+ 0x01010001u,
+ 11f,
+ 10f,
+ new ClientObject
+ {
+ ObjectId = 0x50000010u,
+ Type = ItemType.Creature,
+ });
+
+ Assert.Null(RuntimeHostileTargetQuery.FindClosest(runtime));
+ }
+
+ private static GameRuntime Create()
+ {
+ var operations = new Operations();
+ return new GameRuntime(new GameRuntimeDependencies(
+ operations,
+ operations,
+ operations,
+ operations));
+ }
+
+ private static ClientObject PlayerObject(uint id) => new()
+ {
+ ObjectId = id,
+ Type = ItemType.Creature,
+ PublicWeenieBitfield =
+ SelectedObjectHealthPolicy.BfPlayer,
+ };
+
+ private static ClientObject Hostile(
+ uint id,
+ uint petOwner = 0u) => new()
+ {
+ ObjectId = id,
+ Type = ItemType.Creature,
+ PublicWeenieBitfield =
+ SelectedObjectHealthPolicy.BfAttackable,
+ PetOwnerId = petOwner,
+ };
+
+ private static void Add(
+ GameRuntime runtime,
+ uint guid,
+ uint landblock,
+ float x,
+ float y,
+ ClientObject item,
+ PhysicsStateFlags state = 0)
+ {
+ RuntimeEntityRecord record = runtime.EntityObjects
+ .RegisterEntity(
+ Spawn(guid, landblock, x, y, state))
+ .Canonical!;
+ Assert.True(runtime.EntityObjects.ApplyAcceptedSpawn(
+ record,
+ record.CreateIntegrationVersion,
+ record.Snapshot,
+ replaceGeneration: false));
+ runtime.InventoryOwner.Objects.AddOrUpdate(item);
+ }
+
+ private static WorldSession.EntitySpawn Spawn(
+ uint guid,
+ uint landblock,
+ float x,
+ float y,
+ PhysicsStateFlags state)
+ {
+ var position = new CreateObject.ServerPosition(
+ landblock,
+ x,
+ y,
+ 5f,
+ 1f,
+ 0f,
+ 0f,
+ 0f);
+ var timestamps = new PhysicsTimestamps(
+ Position: 1,
+ Movement: 1,
+ State: 1,
+ Vector: 1,
+ Teleport: 0,
+ ServerControlledMove: 1,
+ ForcePosition: 0,
+ ObjDesc: 1,
+ Instance: 1);
+ var physics = new PhysicsSpawnData(
+ RawState: (uint)state,
+ Position: position,
+ Movement: null,
+ AnimationFrame: null,
+ SetupTableId: 0x02000001u,
+ MotionTableId: null,
+ SoundTableId: null,
+ PhysicsScriptTableId: null,
+ Parent: null,
+ Children: null,
+ Scale: null,
+ Friction: null,
+ Elasticity: null,
+ Translucency: null,
+ Velocity: null,
+ Acceleration: null,
+ AngularVelocity: null,
+ DefaultScriptType: null,
+ DefaultScriptIntensity: null,
+ Timestamps: timestamps);
+ return new WorldSession.EntitySpawn(
+ guid,
+ position,
+ 0x02000001u,
+ [],
+ [],
+ [],
+ null,
+ null,
+ guid.ToString("X8"),
+ null,
+ null,
+ null,
+ PhysicsState: physics.RawState,
+ InstanceSequence: 1,
+ MovementSequence: 1,
+ ServerControlSequence: 1,
+ PositionSequence: 1,
+ Physics: physics);
+ }
+
+ private sealed class Operations :
+ IRuntimeCombatAttackOperations,
+ IRuntimeCombatTargetOperations,
+ IRuntimeCombatModeOperations,
+ IRuntimeSpellCastOperations
+ {
+ public bool CanStartAttack() => false;
+ public void PrepareAttackRequest() { }
+ public bool SendAttack(AttackHeight height, float power) => false;
+ public void SendCancelAttack() { }
+ public bool IsDualWield => false;
+ public bool PlayerReadyForAttack => false;
+ public bool AutoRepeatAttack => false;
+ public bool AutoTarget => false;
+ public uint? SelectClosestTarget() => null;
+ public bool IsInWorld => false;
+ public IReadOnlyList GetOrderedEquipment() => [];
+ public void NotifyExplicitCombatModeRequest() { }
+ public void SendChangeCombatMode(CombatMode mode) { }
+ public uint LocalPlayerId => 0u;
+ public bool CanSend => false;
+ public bool HasRequiredComponents(uint spellId) => false;
+ public bool IsTargetCompatible(
+ uint targetId,
+ SpellMetadata spell,
+ bool showMessage) => false;
+ public void StopCompletely() { }
+ public void SendUntargeted(uint spellId) { }
+ public void SendTargeted(uint targetId, uint spellId) { }
+ public void DisplayMessage(string message) { }
+ public void IncrementBusy() { }
+ }
+}
diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerMovementStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerMovementStateTests.cs
index 5e9d0ce9..1c6194e9 100644
--- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerMovementStateTests.cs
+++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerMovementStateTests.cs
@@ -40,14 +40,87 @@ public sealed class RuntimeLocalPlayerMovementStateTests
public void GraphicalAndDirectCommandsMutateOneAutorunLatch()
{
using var movement = new RuntimeLocalPlayerMovementState();
+ var input = new MovementInput(
+ Forward: true,
+ Run: true);
Assert.True(movement.Execute(RuntimeMovementCommand.ToggleRunLock));
+ movement.SetCommandInput(input);
Assert.True(movement.AutoRunActive);
Assert.True(movement.View.Snapshot.AutoRunActive);
+ Assert.True(movement.HasCommandInput);
+ Assert.Equal(input, movement.CommandInput);
+ Assert.Equal(input, movement.View.Snapshot.CommandInput);
Assert.True(movement.Execute(RuntimeMovementCommand.Stop));
Assert.False(movement.AutoRunActive);
Assert.False(movement.View.Snapshot.AutoRunActive);
+ Assert.False(movement.HasCommandInput);
+ Assert.False(movement.View.Snapshot.HasCommandInput);
+ }
+
+ [Fact]
+ public void CommandInputIsDeduplicatedAndResetWithSessionIntent()
+ {
+ using var movement = new RuntimeLocalPlayerMovementState();
+ var input = new MovementInput(
+ StrafeLeft: true,
+ Run: true);
+
+ movement.SetCommandInput(input);
+ long revision = movement.Revision;
+ movement.SetCommandInput(input);
+
+ Assert.Equal(revision, movement.Revision);
+ Assert.True(movement.Snapshot.HasCommandInput);
+
+ movement.ResetInputIntent();
+
+ Assert.False(movement.HasCommandInput);
+ Assert.Equal(default, movement.CommandInput);
+ Assert.Equal(revision + 1, movement.Revision);
+ }
+
+ [Theory]
+ [InlineData(RuntimeMovementCommand.Ready, MotionCommand.Ready)]
+ [InlineData(RuntimeMovementCommand.Crouch, MotionCommand.Crouch)]
+ [InlineData(RuntimeMovementCommand.Sit, MotionCommand.Sitting)]
+ [InlineData(RuntimeMovementCommand.Sleep, MotionCommand.Sleeping)]
+ public void StanceCommandsUseCanonicalControllerAndEmitOneMovementEdge(
+ RuntimeMovementCommand command,
+ uint expectedMotion)
+ {
+ var controller = new PlayerMovementController(new PhysicsEngine());
+ using var movement = new RuntimeLocalPlayerMovementState
+ {
+ Controller = controller,
+ };
+ movement.SetCommandInput(
+ new MovementInput(Forward: true, Run: true));
+
+ Assert.True(movement.Execute(command));
+ Assert.False(movement.HasCommandInput);
+ Assert.Equal(
+ expectedMotion,
+ controller.Motion.RawState.ForwardCommand);
+
+ MovementResult first = controller.Update(
+ 1f / 60f,
+ default);
+ MovementResult second = controller.Update(
+ 1f / 60f,
+ default);
+
+ Assert.True(first.ShouldSendMovementEvent);
+ Assert.False(second.ShouldSendMovementEvent);
+ if (command == RuntimeMovementCommand.Ready)
+ {
+ Assert.Null(first.ForwardCommand);
+ }
+ else
+ {
+ Assert.Equal(expectedMotion, first.ForwardCommand);
+ }
}
[Fact]
diff --git a/tests/AcDream.Runtime.Tests/NoWindowGameRuntimeHostTests.cs b/tests/AcDream.Runtime.Tests/NoWindowGameRuntimeHostTests.cs
index e087e0ee..3e94878f 100644
--- a/tests/AcDream.Runtime.Tests/NoWindowGameRuntimeHostTests.cs
+++ b/tests/AcDream.Runtime.Tests/NoWindowGameRuntimeHostTests.cs
@@ -581,6 +581,8 @@ public sealed class NoWindowGameRuntimeHostTests
throw new InvalidOperationException("observer");
public void OnPortal(in RuntimePortalDelta delta) =>
throw new InvalidOperationException("observer");
+ public void OnCombat(in RuntimeCombatDelta delta) =>
+ throw new InvalidOperationException("observer");
}
private static WorldSession.EntitySpawn Spawn(
diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs
index fe577c97..586e774d 100644
--- a/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs
+++ b/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs
@@ -518,6 +518,52 @@ public sealed class RuntimePhysicsStateTests
Assert.Equal(record.FullCellId, snapshot.FullCellId);
}
+ [Fact]
+ public void RemoteVelocityStalenessUsesInjectedRuntimeClock()
+ {
+ var time = new ManualTimeProvider(
+ DateTimeOffset.UnixEpoch.AddSeconds(100d));
+ using var lifetime = new RuntimeEntityObjectLifetime(
+ timeProvider: time);
+ RuntimeEntityRecord record =
+ lifetime.Entities.AddActive(Spawn(0x70000025u, 1));
+ var remote = new RemoteMotion
+ {
+ HasServerVelocity = true,
+ LastServerPosTime = 99d,
+ ServerVelocity = Vector3.UnitX,
+ };
+ remote.Body.Position = new Vector3(10f, 20f, 5f);
+ remote.Body.TransientState = TransientStateFlags.Active
+ | TransientStateFlags.Contact
+ | TransientStateFlags.OnWalkable;
+ lifetime.Physics.SetRemoteMotion(record, remote);
+ lifetime.Physics.AcknowledgeSpatialProjection(record, spatial: true);
+ var updater = new RuntimeRemotePhysicsUpdater(lifetime.Physics);
+ Vector3? cycle = null;
+
+ Assert.True(updater.Tick(
+ record,
+ remote,
+ objectScale: 1f,
+ sequencer: null,
+ dt: 0.1f,
+ objectClockEpoch: record.ObjectClockEpoch,
+ new MotionDeltaFrame
+ {
+ Orientation = Quaternion.Identity,
+ },
+ radius: 0.48f,
+ height: 1.835f,
+ liveCenterX: 1,
+ liveCenterY: 1,
+ applyStaleVelocityCycle: value => cycle = value));
+
+ Assert.False(remote.HasServerVelocity);
+ Assert.Equal(Vector3.Zero, remote.ServerVelocity);
+ Assert.Equal(Vector3.Zero, cycle);
+ }
+
[Fact]
public void PhysicsBodyAcquisitionIsCanonicalAndRejectsGuidReuse()
{
@@ -551,6 +597,12 @@ public sealed class RuntimePhysicsStateTests
Assert.False(stale.PhysicsBodyAcquisitionInProgress);
}
+ private sealed class ManualTimeProvider(DateTimeOffset utcNow)
+ : TimeProvider
+ {
+ public override DateTimeOffset GetUtcNow() => utcNow;
+ }
+
[Fact]
public void PhysicsHostIdentityAndLookupBelongToExactRuntimeIncarnation()
{
diff --git a/tests/AcDream.Runtime.Tests/RuntimeGenerationResetTests.cs b/tests/AcDream.Runtime.Tests/RuntimeGenerationResetTests.cs
index 1b6c8d00..468f2100 100644
--- a/tests/AcDream.Runtime.Tests/RuntimeGenerationResetTests.cs
+++ b/tests/AcDream.Runtime.Tests/RuntimeGenerationResetTests.cs
@@ -60,11 +60,14 @@ public sealed class RuntimeGenerationResetTests
Assert.Equal(1, host.DrainCalls);
Assert.Equal(1, host.CompleteCalls);
Assert.All(
- observer.Entity.Concat(observer.Inventory),
+ observer.Combat
+ .Concat(observer.Entity)
+ .Concat(observer.Inventory),
stamp => Assert.Equal(retiring, stamp.Generation));
Assert.Equal(
- [1UL, 2UL],
- observer.Inventory
+ [1UL, 2UL, 3UL, 4UL, 5UL],
+ observer.Combat
+ .Concat(observer.Inventory)
.Concat(observer.Entity)
.OrderBy(static stamp => stamp.Sequence)
.Select(static stamp => stamp.Sequence));
@@ -312,6 +315,7 @@ public sealed class RuntimeGenerationResetTests
{
public List Entity { get; } = [];
public List Inventory { get; } = [];
+ public List Combat { get; } = [];
public void OnLifecycle(in RuntimeLifecycleDelta delta) { }
public void OnCommand(in RuntimeCommandDelta delta) { }
@@ -322,6 +326,8 @@ public sealed class RuntimeGenerationResetTests
public void OnChat(in RuntimeChatDelta delta) { }
public void OnMovement(in RuntimeMovementDelta delta) { }
public void OnPortal(in RuntimePortalDelta delta) { }
+ public void OnCombat(in RuntimeCombatDelta delta) =>
+ Combat.Add(delta.Stamp);
}
private sealed class Operations :
diff --git a/tests/AcDream.Runtime.Tests/Session/DirectGameRuntimeCommandAdapterTests.cs b/tests/AcDream.Runtime.Tests/Session/DirectGameRuntimeCommandAdapterTests.cs
index 7ad40425..1c5edeab 100644
--- a/tests/AcDream.Runtime.Tests/Session/DirectGameRuntimeCommandAdapterTests.cs
+++ b/tests/AcDream.Runtime.Tests/Session/DirectGameRuntimeCommandAdapterTests.cs
@@ -67,6 +67,12 @@ public sealed class DirectGameRuntimeCommandAdapterTests
var gameActions = new List();
operations.Sessions[^1].GameActionCapture =
body => gameActions.Add(body);
+ const uint selectedObject = 0x70000001u;
+ runtime.InventoryOwner.Objects.AddOrUpdate(new ClientObject
+ {
+ ObjectId = selectedObject,
+ Type = ItemType.Misc,
+ });
RuntimeCommandResult chat = adapter.Chat.Execute(
runtime.Generation,
@@ -76,9 +82,128 @@ public sealed class DirectGameRuntimeCommandAdapterTests
RuntimeCommandResult portal = adapter.Portal.Execute(
runtime.Generation,
RuntimePortalCommand.RecallLifestone);
- RuntimeCommandResult unsupported = adapter.Movement.Execute(
- runtime.Generation,
- RuntimeMovementCommand.Stop);
+ runtime.CommunicationOwner.TurbineChat.OnChannelsReceived(
+ allegianceRoom: 0x10u,
+ generalRoom: 0x11u,
+ tradeRoom: 0x12u,
+ lfgRoom: 0x13u,
+ roleplayRoom: 0x14u,
+ olthoiRoom: 0x15u,
+ societyRoom: 0x16u,
+ societyCelestialHandRoom: 0u,
+ societyEldrytchWebRoom: 0u,
+ societyRadiantBloodRoom: 0u);
+ RuntimeCommandResult[] stateAndWireCommands =
+ [
+ adapter.Selection.SelectObject(
+ runtime.Generation,
+ selectedObject),
+ adapter.Selection.Clear(runtime.Generation),
+ adapter.Movement.SetIntent(
+ runtime.Generation,
+ new MovementInput(Forward: true, Run: true)),
+ adapter.Movement.ClearIntent(runtime.Generation),
+ adapter.Movement.Execute(
+ runtime.Generation,
+ RuntimeMovementCommand.Stop),
+ adapter.Chat.Execute(
+ runtime.Generation,
+ new RuntimeChatCommand(
+ RuntimeChatChannel.Fellowship,
+ "group")),
+ adapter.Chat.Execute(
+ runtime.Generation,
+ new RuntimeChatCommand(
+ RuntimeChatChannel.General,
+ "global")),
+ adapter.InventoryState.AddShortcut(
+ runtime.Generation,
+ new RuntimeShortcutCommand(0, 0x70000001u, 0u)),
+ adapter.InventoryState.RemoveShortcut(
+ runtime.Generation,
+ 0),
+ adapter.Spellbook.AddFavorite(
+ runtime.Generation,
+ tabIndex: 0,
+ position: 0,
+ spellId: 7u),
+ adapter.Spellbook.RemoveFavorite(
+ runtime.Generation,
+ tabIndex: 0,
+ spellId: 7u),
+ adapter.Spellbook.SetFilter(
+ runtime.Generation,
+ filters: 3u),
+ adapter.Spellbook.ForgetSpell(
+ runtime.Generation,
+ spellId: 7u),
+ adapter.Spellbook.SetDesiredComponent(
+ runtime.Generation,
+ componentId: 11u,
+ amount: 3u),
+ adapter.Spellbook.ClearDesiredComponents(
+ runtime.Generation),
+ adapter.Character.Advance(
+ runtime.Generation,
+ new RuntimeAdvancementCommand(
+ RuntimeAdvancementKind.Attribute,
+ StatId: 1u,
+ Cost: 10u)),
+ adapter.Character.Advance(
+ runtime.Generation,
+ new RuntimeAdvancementCommand(
+ RuntimeAdvancementKind.Vital,
+ StatId: 2u,
+ Cost: 11u)),
+ adapter.Character.Advance(
+ runtime.Generation,
+ new RuntimeAdvancementCommand(
+ RuntimeAdvancementKind.Skill,
+ StatId: 3u,
+ Cost: 12u)),
+ adapter.Character.Advance(
+ runtime.Generation,
+ new RuntimeAdvancementCommand(
+ RuntimeAdvancementKind.TrainSkill,
+ StatId: 4u,
+ Cost: 1u)),
+ adapter.Character.SetOptions1(
+ runtime.Generation,
+ options: 0x1234u),
+ adapter.Social.Execute(
+ runtime.Generation,
+ new RuntimeFriendCommand(
+ RuntimeFriendCommandKind.Add,
+ Name: "Friend")),
+ adapter.Social.Execute(
+ runtime.Generation,
+ new RuntimeFriendCommand(
+ RuntimeFriendCommandKind.Remove,
+ CharacterId: 0x50000003u)),
+ adapter.Social.Execute(
+ runtime.Generation,
+ new RuntimeFriendCommand(
+ RuntimeFriendCommandKind.Clear)),
+ adapter.Social.Execute(
+ runtime.Generation,
+ new RuntimeSquelchCommand(
+ RuntimeSquelchScope.Character,
+ Add: true,
+ CharacterId: 0x50000004u,
+ Name: "Muted")),
+ adapter.Social.Execute(
+ runtime.Generation,
+ new RuntimeSquelchCommand(
+ RuntimeSquelchScope.Account,
+ Add: true,
+ Name: "AccountMuted")),
+ adapter.Social.Execute(
+ runtime.Generation,
+ new RuntimeSquelchCommand(
+ RuntimeSquelchScope.Global,
+ Add: true,
+ MessageType: 2u)),
+ ];
RuntimeSessionStartResult reconnected =
adapter.Session.Reconnect(runtime.Generation);
@@ -87,6 +212,10 @@ public sealed class DirectGameRuntimeCommandAdapterTests
new RuntimeChatCommand(
RuntimeChatChannel.Say,
"stale"));
+ RuntimeCommandResult staleMovement =
+ adapter.Movement.SetIntent(
+ firstGeneration,
+ new MovementInput(Forward: true));
Assert.Equal(RuntimeSessionStartStatus.Connected, started.Status);
Assert.Equal(
@@ -94,13 +223,19 @@ public sealed class DirectGameRuntimeCommandAdapterTests
reconnected.Status);
Assert.True(chat.Accepted);
Assert.True(portal.Accepted);
- Assert.Equal(
- RuntimeCommandStatus.Unsupported,
- unsupported.Status);
+ Assert.All(
+ stateAndWireCommands,
+ result => Assert.Equal(
+ RuntimeCommandStatus.Accepted,
+ result.Status));
Assert.Equal(
RuntimeCommandStatus.StaleGeneration,
stale.Status);
- Assert.Equal(2, gameActions.Count);
+ Assert.Equal(
+ RuntimeCommandStatus.StaleGeneration,
+ staleMovement.Status);
+ Assert.False(runtime.MovementOwner.HasCommandInput);
+ Assert.True(gameActions.Count >= 20);
Assert.Contains(
trace.Entries,
entry => entry.Kind == RuntimeTraceKind.Command
@@ -112,6 +247,9 @@ public sealed class DirectGameRuntimeCommandAdapterTests
entry => entry.Kind == RuntimeTraceKind.Command
&& (entry.Code >> 16)
== (int)RuntimeCommandDomain.Portal);
+ Assert.Contains(
+ trace.Entries,
+ entry => entry.Kind == RuntimeTraceKind.Combat);
RuntimeTeardownAcknowledgement stopped =
adapter.Session.Stop(runtime.Generation);