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