feat(headless): complete deterministic bot command parity
This commit is contained in:
parent
7e8acb74dd
commit
38e83640d9
37 changed files with 2805 additions and 295 deletions
|
|
@ -686,6 +686,7 @@ internal sealed class SessionPlayerCompositionPhase
|
|||
d.PlayerOutbound,
|
||||
liveSessionSource);
|
||||
var localPlayerFrame = new RetailLocalPlayerFrameController(
|
||||
d.Runtime,
|
||||
localPlayerFrameRuntime,
|
||||
d.MovementInput);
|
||||
var liveObjectFrame = new LiveObjectFrameController(
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ using AcDream.UI.Abstractions.Input;
|
|||
namespace AcDream.App.Input;
|
||||
|
||||
internal interface IMovementInputSource
|
||||
: IRuntimeMovementInputSource
|
||||
{
|
||||
MovementInput Capture();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -53,6 +53,9 @@ internal sealed class DispatcherMovementInputSource : IMovementInputSource
|
|||
if (_capture?.DevToolsWantCaptureKeyboard == true)
|
||||
return default;
|
||||
|
||||
if (_movement.HasCommandInput)
|
||||
return _movement.CommandInput;
|
||||
|
||||
if (_dispatcher is not { } dispatcher)
|
||||
return default;
|
||||
|
||||
|
|
|
|||
|
|
@ -17,15 +17,12 @@ internal interface ILocalPlayerPresentationRuntime
|
|||
PlayerMovementController? Controller { get; }
|
||||
}
|
||||
|
||||
internal interface ILocalPlayerFrameRuntime : ILocalPlayerPresentationRuntime
|
||||
internal interface ILocalPlayerFrameRuntime :
|
||||
ILocalPlayerPresentationRuntime,
|
||||
IRuntimeLocalPlayerFrameHost
|
||||
{
|
||||
uint ResolveLocalEntityId();
|
||||
void HandleTargeting();
|
||||
bool IsHidden { get; }
|
||||
RetailObjectClockDisposition ObjectClockDisposition { get; }
|
||||
void Project(PlayerMovementController controller, MovementResult movement, bool hidden);
|
||||
void SendPreNetwork(PlayerMovementController controller, MovementResult movement, bool hidden);
|
||||
void SendPostNetwork(PlayerMovementController controller, bool hidden);
|
||||
bool IRuntimeLocalPlayerFrameHost.CanAdvancePlayer =>
|
||||
CanPresentPlayer;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using AcDream.App.Update;
|
||||
using AcDream.Runtime;
|
||||
|
||||
namespace AcDream.App.Input;
|
||||
|
||||
|
|
@ -14,17 +15,7 @@ namespace AcDream.App.Input;
|
|||
/// </remarks>
|
||||
internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFramePhase
|
||||
{
|
||||
private readonly ILocalPlayerFrameRuntime _runtime;
|
||||
private readonly IMovementInputSource _movementInput;
|
||||
|
||||
private AdvancedFrame? _advancedFrame;
|
||||
|
||||
private readonly record struct AdvancedFrame(
|
||||
PlayerMovementController Controller,
|
||||
MovementResult Movement,
|
||||
bool ObjectAdvanced,
|
||||
bool Hidden,
|
||||
bool ObjectQuantumAdvanced);
|
||||
private readonly RuntimeLocalPlayerFrameController _runtime;
|
||||
|
||||
public readonly record struct PresentationFrame(
|
||||
MovementResult Movement,
|
||||
|
|
@ -35,19 +26,29 @@ internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFram
|
|||
/// Whether the pre-network Hidden player update admitted a complete object
|
||||
/// quantum whose retained CPartArray pose must be sampled this frame.
|
||||
/// </summary>
|
||||
internal bool HiddenPartPoseDirty => _advancedFrame is
|
||||
{
|
||||
Hidden: true,
|
||||
ObjectQuantumAdvanced: true,
|
||||
};
|
||||
internal bool HiddenPartPoseDirty =>
|
||||
_runtime.HiddenPartPoseDirty;
|
||||
|
||||
public RetailLocalPlayerFrameController(
|
||||
ILocalPlayerFrameRuntime runtime,
|
||||
IMovementInputSource movementInput)
|
||||
{
|
||||
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
|
||||
_movementInput = movementInput
|
||||
?? throw new ArgumentNullException(nameof(movementInput));
|
||||
_runtime = new RuntimeLocalPlayerFrameController(
|
||||
runtime ?? throw new ArgumentNullException(nameof(runtime)),
|
||||
movementInput
|
||||
?? throw new ArgumentNullException(nameof(movementInput)));
|
||||
}
|
||||
|
||||
public RetailLocalPlayerFrameController(
|
||||
GameRuntime gameRuntime,
|
||||
ILocalPlayerFrameRuntime runtime,
|
||||
IMovementInputSource movementInput)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(gameRuntime);
|
||||
_runtime = gameRuntime.CreateLocalPlayerFrameController(
|
||||
runtime ?? throw new ArgumentNullException(nameof(runtime)),
|
||||
movementInput
|
||||
?? throw new ArgumentNullException(nameof(movementInput)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -55,64 +56,7 @@ internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFram
|
|||
/// the inbound-network barrier.
|
||||
/// </summary>
|
||||
public void AdvanceBeforeNetwork(float deltaSeconds)
|
||||
{
|
||||
_advancedFrame = null;
|
||||
PlayerMovementController? controller = _runtime.Controller;
|
||||
if (!_runtime.CanPresentPlayer || controller is null)
|
||||
return;
|
||||
|
||||
if (!float.IsFinite(deltaSeconds) || deltaSeconds <= 0f)
|
||||
{
|
||||
bool rejectedHidden = _runtime.IsHidden;
|
||||
MovementResult rejected = controller.CapturePresentationResult();
|
||||
_runtime.Project(controller, rejected, rejectedHidden);
|
||||
_advancedFrame = new AdvancedFrame(
|
||||
controller,
|
||||
rejected,
|
||||
ObjectAdvanced: false,
|
||||
Hidden: rejectedHidden,
|
||||
ObjectQuantumAdvanced: false);
|
||||
return;
|
||||
}
|
||||
|
||||
controller.LocalEntityId = _runtime.ResolveLocalEntityId();
|
||||
|
||||
bool hidden = _runtime.IsHidden;
|
||||
if (_runtime.ObjectClockDisposition
|
||||
is AcDream.Core.Physics.RetailObjectClockDisposition.Suspend)
|
||||
{
|
||||
// CPhysicsObj::update_object returns before advancing time for a
|
||||
// Frozen or cell-less object. Keep the retained projection live,
|
||||
// but emit neither input actions nor AutonomousPosition from a
|
||||
// physics tick that retail did not run.
|
||||
controller.SuspendObjectUpdate(deltaSeconds);
|
||||
MovementResult paused = controller.CapturePresentationResult();
|
||||
_runtime.Project(controller, paused, hidden);
|
||||
_advancedFrame = new AdvancedFrame(
|
||||
controller,
|
||||
paused,
|
||||
ObjectAdvanced: false,
|
||||
Hidden: hidden,
|
||||
ObjectQuantumAdvanced: false);
|
||||
return;
|
||||
}
|
||||
|
||||
MovementResult movement = hidden
|
||||
? controller.TickHidden(deltaSeconds, _runtime.HandleTargeting)
|
||||
: controller.Update(
|
||||
deltaSeconds,
|
||||
_movementInput.Capture(),
|
||||
_runtime.HandleTargeting);
|
||||
|
||||
_runtime.Project(controller, movement, hidden);
|
||||
_runtime.SendPreNetwork(controller, movement, hidden);
|
||||
_advancedFrame = new AdvancedFrame(
|
||||
controller,
|
||||
movement,
|
||||
ObjectAdvanced: true,
|
||||
Hidden: hidden,
|
||||
ObjectQuantumAdvanced: controller.AdvancedObjectQuantumLastTick);
|
||||
}
|
||||
=> _runtime.AdvanceBeforeNetwork(deltaSeconds);
|
||||
|
||||
/// <summary>
|
||||
/// Runs retail's post-inbound command-interpreter phase. The split
|
||||
|
|
@ -120,32 +64,7 @@ internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFram
|
|||
/// then AutonomousPosition is evaluated from that current state.
|
||||
/// </summary>
|
||||
public void RunPostNetworkCommandPhase()
|
||||
{
|
||||
PlayerMovementController? controller = _runtime.Controller;
|
||||
if (!_runtime.CanPresentPlayer || controller is null)
|
||||
return;
|
||||
|
||||
bool hidden = _runtime.IsHidden;
|
||||
if (_advancedFrame is { } advanced
|
||||
&& ReferenceEquals(advanced.Controller, controller))
|
||||
{
|
||||
MovementResult movement = RefreshSpatialFields(
|
||||
advanced.Movement,
|
||||
controller);
|
||||
_runtime.Project(controller, movement, hidden);
|
||||
_advancedFrame = new AdvancedFrame(
|
||||
controller,
|
||||
movement,
|
||||
advanced.ObjectAdvanced,
|
||||
advanced.Hidden,
|
||||
advanced.ObjectQuantumAdvanced);
|
||||
|
||||
if (!advanced.ObjectAdvanced)
|
||||
return;
|
||||
}
|
||||
|
||||
_runtime.SendPostNetwork(controller, hidden);
|
||||
}
|
||||
=> _runtime.RunPostNetworkCommandPhase();
|
||||
|
||||
/// <summary>
|
||||
/// Produces the post-network presentation sample without advancing
|
||||
|
|
@ -154,39 +73,15 @@ internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFram
|
|||
/// </summary>
|
||||
public bool TryGetPresentationAfterNetwork(out PresentationFrame frame)
|
||||
{
|
||||
frame = default;
|
||||
PlayerMovementController? controller = _runtime.Controller;
|
||||
if (!_runtime.CanPresentPlayer || controller is null)
|
||||
return false;
|
||||
|
||||
bool hidden = _runtime.IsHidden;
|
||||
if (_advancedFrame is { } advanced
|
||||
&& ReferenceEquals(advanced.Controller, controller))
|
||||
{
|
||||
MovementResult movement = RefreshSpatialFields(
|
||||
advanced.Movement,
|
||||
controller);
|
||||
frame = new PresentationFrame(
|
||||
movement,
|
||||
hidden,
|
||||
AdvancedBeforeNetwork: advanced.ObjectAdvanced);
|
||||
return true;
|
||||
}
|
||||
|
||||
MovementResult initial = controller.CapturePresentationResult();
|
||||
_runtime.Project(controller, initial, hidden);
|
||||
frame = new PresentationFrame(initial, hidden, AdvancedBeforeNetwork: false);
|
||||
return true;
|
||||
bool available = _runtime.TryGetPresentationAfterNetwork(
|
||||
out AcDream.Runtime.Gameplay.RuntimeLocalPlayerPresentationFrame
|
||||
shared);
|
||||
frame = available
|
||||
? new PresentationFrame(
|
||||
shared.Movement,
|
||||
shared.Hidden,
|
||||
shared.AdvancedBeforeNetwork)
|
||||
: default;
|
||||
return available;
|
||||
}
|
||||
|
||||
private static MovementResult RefreshSpatialFields(
|
||||
MovementResult movement,
|
||||
PlayerMovementController controller) =>
|
||||
movement with
|
||||
{
|
||||
Position = controller.Position,
|
||||
RenderPosition = controller.RenderPosition,
|
||||
CellId = controller.CellId,
|
||||
IsOnGround = controller.CanSendPositionEvent,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -14,5 +14,6 @@
|
|||
<InternalsVisibleTo Include="AcDream.Core.Net.Tests" />
|
||||
<InternalsVisibleTo Include="AcDream.App.Tests" />
|
||||
<InternalsVisibleTo Include="AcDream.Runtime.Tests" />
|
||||
<InternalsVisibleTo Include="AcDream.Headless.Tests" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure projection of ClientMagicSystem::ObjectCompatibleWithSpellTargetType
|
||||
/// Pure projection of
|
||||
/// <c>ClientMagicSystem::ObjectCompatibleWithSpellTargetType</c>
|
||||
/// (0x00567230). The caller owns target lookup and message presentation.
|
||||
/// </summary>
|
||||
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}.");
|
||||
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
using AcDream.Core.Combat;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Spells;
|
||||
using AcDream.Runtime;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
using AcDream.Runtime.Session;
|
||||
|
||||
namespace AcDream.Headless.Hosting;
|
||||
|
||||
|
|
@ -17,64 +19,251 @@ internal sealed class HeadlessGameplayOperations
|
|||
IRuntimeCombatModeOperations,
|
||||
IRuntimeSpellCastOperations
|
||||
{
|
||||
private sealed class SessionRoute(
|
||||
HeadlessGameplayOperations owner,
|
||||
WorldSession session)
|
||||
: ILiveSessionCommandRouting
|
||||
{
|
||||
private bool _active;
|
||||
|
||||
public void Activate()
|
||||
{
|
||||
if (_active)
|
||||
return;
|
||||
owner.Activate(session, this);
|
||||
_active = true;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
owner.Deactivate(this);
|
||||
_active = false;
|
||||
}
|
||||
}
|
||||
|
||||
private readonly object _gate = new();
|
||||
private GameRuntime? _runtime;
|
||||
private WorldSession? _session;
|
||||
private SessionRoute? _route;
|
||||
|
||||
internal void Bind(GameRuntime runtime) =>
|
||||
_runtime = runtime
|
||||
?? throw new ArgumentNullException(nameof(runtime));
|
||||
|
||||
public bool CanStartAttack() => false;
|
||||
internal ILiveSessionCommandRouting CreateRoute(
|
||||
WorldSession session)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(session);
|
||||
return new SessionRoute(this, session);
|
||||
}
|
||||
|
||||
public bool CanStartAttack()
|
||||
{
|
||||
GameRuntime runtime = RequireRuntime();
|
||||
if (!IsInWorld
|
||||
|| !CombatInputPlanner.SupportsTargetedAttack(
|
||||
runtime.ActionOwner.Combat.CurrentMode))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return GetSelectedOrClosestTarget(runtime) is not null;
|
||||
}
|
||||
|
||||
public void PrepareAttackRequest()
|
||||
{
|
||||
_ = RequireRuntime().MovementOwner.PrepareForAttackRequest();
|
||||
}
|
||||
|
||||
public bool SendAttack(AttackHeight height, float power)
|
||||
{
|
||||
GameRuntime runtime = RequireRuntime();
|
||||
uint? target = GetSelectedOrClosestTarget(runtime);
|
||||
if (target is null || !TryGetSession(out WorldSession? session))
|
||||
return false;
|
||||
|
||||
power = Math.Clamp(power, 0f, 1f);
|
||||
if (runtime.ActionOwner.Combat.CurrentMode == CombatMode.Missile)
|
||||
session!.SendMissileAttack(target.Value, height, power);
|
||||
else
|
||||
session!.SendMeleeAttack(target.Value, height, power);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool SendAttack(AttackHeight height, float power) => false;
|
||||
public void SendCancelAttack()
|
||||
{
|
||||
if (TryGetSession(out WorldSession? session))
|
||||
session!.SendCancelAttack();
|
||||
}
|
||||
|
||||
public bool IsDualWield => false;
|
||||
public bool PlayerReadyForAttack => false;
|
||||
public bool IsDualWield =>
|
||||
RequireRuntime().MovementOwner.IsDualWield;
|
||||
public bool PlayerReadyForAttack
|
||||
{
|
||||
get
|
||||
{
|
||||
GameRuntime runtime = RequireRuntime();
|
||||
return runtime.MovementOwner.IsReadyForAttack(
|
||||
runtime.ActionOwner.Combat.CurrentMode);
|
||||
}
|
||||
}
|
||||
public bool AutoRepeatAttack => false;
|
||||
public bool AutoTarget => false;
|
||||
public uint? SelectClosestTarget() => null;
|
||||
public bool AutoTarget => true;
|
||||
|
||||
public uint? SelectClosestTarget()
|
||||
{
|
||||
GameRuntime runtime = RequireRuntime();
|
||||
uint? closest = RuntimeHostileTargetQuery.FindClosest(runtime);
|
||||
if (closest is { } target)
|
||||
{
|
||||
runtime.ActionOwner.Selection.Select(
|
||||
target,
|
||||
AcDream.Core.Selection.SelectionChangeSource.Keyboard);
|
||||
}
|
||||
else
|
||||
{
|
||||
runtime.ActionOwner.Selection.Clear(
|
||||
AcDream.Core.Selection.SelectionChangeSource.Keyboard);
|
||||
}
|
||||
return closest;
|
||||
}
|
||||
|
||||
public bool IsInWorld => _runtime?.Session.IsInWorld == true;
|
||||
public IReadOnlyList<ClientObject> GetOrderedEquipment() => [];
|
||||
public IReadOnlyList<ClientObject> GetOrderedEquipment()
|
||||
{
|
||||
GameRuntime runtime = RequireRuntime();
|
||||
return runtime.InventoryOwner.Objects.GetEquippedBy(
|
||||
runtime.PlayerIdentity.ServerGuid);
|
||||
}
|
||||
|
||||
public void NotifyExplicitCombatModeRequest()
|
||||
{
|
||||
}
|
||||
|
||||
public void SendChangeCombatMode(CombatMode mode)
|
||||
{
|
||||
if (TryGetSession(out WorldSession? session))
|
||||
session!.SendChangeCombatMode(mode);
|
||||
}
|
||||
|
||||
public uint LocalPlayerId =>
|
||||
_runtime?.PlayerIdentity.ServerGuid ?? 0u;
|
||||
public bool CanSend => false;
|
||||
public bool HasRequiredComponents(uint spellId) => false;
|
||||
public bool CanSend => TryGetSession(out _);
|
||||
|
||||
public bool HasRequiredComponents(uint spellId)
|
||||
{
|
||||
GameRuntime runtime = RequireRuntime();
|
||||
ClientObject? player = runtime.InventoryOwner.Objects.Get(
|
||||
runtime.PlayerIdentity.ServerGuid);
|
||||
return player is not null
|
||||
&& !player.Properties.GetBool(
|
||||
SpellComponentsRequiredProperty,
|
||||
true);
|
||||
}
|
||||
|
||||
public bool IsTargetCompatible(
|
||||
uint targetId,
|
||||
SpellMetadata spell,
|
||||
bool showMessage) => false;
|
||||
bool showMessage)
|
||||
{
|
||||
GameRuntime runtime = RequireRuntime();
|
||||
ClientObject? target =
|
||||
runtime.InventoryOwner.Objects.Get(targetId);
|
||||
if (target is null)
|
||||
return false;
|
||||
SpellTargetPolicyResult result =
|
||||
RetailSpellTargetPolicy.Evaluate(
|
||||
runtime.PlayerIdentity.ServerGuid,
|
||||
target,
|
||||
spell);
|
||||
if (showMessage
|
||||
&& !result.Allowed
|
||||
&& result.Message is { } message)
|
||||
{
|
||||
DisplayMessage(message);
|
||||
}
|
||||
return result.Allowed;
|
||||
}
|
||||
|
||||
public void StopCompletely()
|
||||
{
|
||||
_ = RequireRuntime().MovementOwner.PrepareForAttackRequest();
|
||||
}
|
||||
|
||||
public void SendUntargeted(uint spellId)
|
||||
{
|
||||
if (TryGetSession(out WorldSession? session))
|
||||
session!.SendCastUntargetedSpell(spellId);
|
||||
}
|
||||
|
||||
public void SendTargeted(uint targetId, uint spellId)
|
||||
{
|
||||
if (TryGetSession(out WorldSession? session))
|
||||
session!.SendCastTargetedSpell(targetId, spellId);
|
||||
}
|
||||
|
||||
public void DisplayMessage(string message)
|
||||
public void DisplayMessage(string message) =>
|
||||
RequireRuntime().CommunicationOwner.Chat.OnSystemMessage(
|
||||
message,
|
||||
chatType: 0u);
|
||||
|
||||
public void IncrementBusy() =>
|
||||
RequireRuntime().ActionOwner.Transactions
|
||||
.IncrementBusyCount();
|
||||
|
||||
private uint? GetSelectedOrClosestTarget(GameRuntime runtime)
|
||||
{
|
||||
uint? selected =
|
||||
runtime.ActionOwner.Selection.SelectedObjectId;
|
||||
if (selected is { } target
|
||||
&& RuntimeHostileTargetQuery.IsHostile(runtime, target))
|
||||
{
|
||||
return target;
|
||||
}
|
||||
return AutoTarget ? SelectClosestTarget() : null;
|
||||
}
|
||||
|
||||
public void IncrementBusy()
|
||||
private GameRuntime RequireRuntime() =>
|
||||
_runtime
|
||||
?? throw new InvalidOperationException(
|
||||
"Headless gameplay operations are not bound.");
|
||||
|
||||
private bool TryGetSession(out WorldSession? session)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
session = _session;
|
||||
return _route is not null
|
||||
&& session is not null
|
||||
&& IsInWorld;
|
||||
}
|
||||
}
|
||||
|
||||
private void Activate(
|
||||
WorldSession session,
|
||||
SessionRoute route)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_route is not null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Headless gameplay operations already have an active session.");
|
||||
}
|
||||
_session = session;
|
||||
_route = route;
|
||||
}
|
||||
}
|
||||
|
||||
private void Deactivate(SessionRoute route)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!ReferenceEquals(_route, route))
|
||||
return;
|
||||
_session = null;
|
||||
_route = null;
|
||||
}
|
||||
}
|
||||
|
||||
private const uint SpellComponentsRequiredProperty = 68u;
|
||||
}
|
||||
|
|
|
|||
124
src/AcDream.Headless/Hosting/HeadlessLocalPlayerFrameHost.cs
Normal file
124
src/AcDream.Headless/Hosting/HeadlessLocalPlayerFrameHost.cs
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
using AcDream.Core.Physics;
|
||||
using AcDream.Runtime;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
using AcDream.Runtime.Session;
|
||||
|
||||
namespace AcDream.Headless.Hosting;
|
||||
|
||||
internal sealed class HeadlessMovementInputSource(
|
||||
RuntimeLocalPlayerMovementState movement)
|
||||
: IRuntimeMovementInputSource
|
||||
{
|
||||
private readonly RuntimeLocalPlayerMovementState _movement =
|
||||
movement ?? throw new ArgumentNullException(nameof(movement));
|
||||
|
||||
public MovementInput Capture()
|
||||
{
|
||||
if (_movement.HasCommandInput)
|
||||
return _movement.CommandInput;
|
||||
return new MovementInput(
|
||||
Forward: _movement.AutoRunActive,
|
||||
Run: true);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class HeadlessLocalPlayerFrameHost
|
||||
: IRuntimeLocalPlayerFrameHost
|
||||
{
|
||||
private readonly GameRuntime _runtime;
|
||||
private readonly LiveSessionHost _session;
|
||||
private readonly LocalPlayerOutboundController _outbound;
|
||||
|
||||
internal HeadlessLocalPlayerFrameHost(
|
||||
GameRuntime runtime,
|
||||
LiveSessionHost session)
|
||||
{
|
||||
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
|
||||
_session = session ?? throw new ArgumentNullException(nameof(session));
|
||||
_outbound = new LocalPlayerOutboundController(
|
||||
static (_, _, _, _, _, _) => { });
|
||||
}
|
||||
|
||||
public bool CanAdvancePlayer =>
|
||||
_runtime.Session.IsInWorld
|
||||
&& _runtime.MovementOwner.Controller is not null;
|
||||
|
||||
public PlayerMovementController? Controller =>
|
||||
_runtime.MovementOwner.Controller;
|
||||
|
||||
public uint ResolveLocalEntityId()
|
||||
{
|
||||
uint player = _runtime.PlayerIdentity.ServerGuid;
|
||||
return player != 0u
|
||||
&& _runtime.EntityObjects.Entities.TryGetActive(
|
||||
player,
|
||||
out var record)
|
||||
? record.LocalEntityId ?? 0u
|
||||
: 0u;
|
||||
}
|
||||
|
||||
public void HandleTargeting()
|
||||
{
|
||||
}
|
||||
|
||||
public bool IsHidden
|
||||
{
|
||||
get
|
||||
{
|
||||
uint player = _runtime.PlayerIdentity.ServerGuid;
|
||||
return player != 0u
|
||||
&& _runtime.EntityObjects.Entities.TryGetActive(
|
||||
player,
|
||||
out var record)
|
||||
&& (record.FinalPhysicsState
|
||||
& PhysicsStateFlags.Hidden) != 0;
|
||||
}
|
||||
}
|
||||
|
||||
public RetailObjectClockDisposition ObjectClockDisposition
|
||||
{
|
||||
get
|
||||
{
|
||||
uint player = _runtime.PlayerIdentity.ServerGuid;
|
||||
if (player == 0u
|
||||
|| !_runtime.EntityObjects.Entities.TryGetActive(
|
||||
player,
|
||||
out var record)
|
||||
|| record.FullCellId == 0u
|
||||
|| (record.FinalPhysicsState
|
||||
& (PhysicsStateFlags.Frozen
|
||||
| PhysicsStateFlags.Static)) != 0)
|
||||
{
|
||||
return RetailObjectClockDisposition.Suspend;
|
||||
}
|
||||
return RetailObjectClockDisposition.Advance;
|
||||
}
|
||||
}
|
||||
|
||||
public void Project(
|
||||
PlayerMovementController controller,
|
||||
MovementResult movement,
|
||||
bool hidden)
|
||||
{
|
||||
// The controller and Runtime physics body are the direct host's
|
||||
// canonical projection. There is no render bucket or shadow mirror.
|
||||
}
|
||||
|
||||
public void SendPreNetwork(
|
||||
PlayerMovementController controller,
|
||||
MovementResult movement,
|
||||
bool hidden) =>
|
||||
_outbound.SendPreNetworkActions(
|
||||
_session.CurrentSession,
|
||||
controller,
|
||||
movement,
|
||||
hidden);
|
||||
|
||||
public void SendPostNetwork(
|
||||
PlayerMovementController controller,
|
||||
bool hidden) =>
|
||||
_outbound.SendPostNetworkPosition(
|
||||
_session.CurrentSession,
|
||||
controller,
|
||||
hidden);
|
||||
}
|
||||
|
|
@ -3,12 +3,76 @@ using AcDream.Headless.Credentials;
|
|||
using AcDream.Headless.Diagnostics;
|
||||
using AcDream.Headless.Policies;
|
||||
using AcDream.Runtime;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
using AcDream.Runtime.Session;
|
||||
|
||||
namespace AcDream.Headless.Hosting;
|
||||
|
||||
internal sealed class HeadlessSessionHost : IDisposable
|
||||
{
|
||||
private sealed class SessionCommandRoute(
|
||||
ILiveSessionCommandRouting gameplay,
|
||||
ILiveSessionCommandRouting commands)
|
||||
: ILiveSessionCommandRouting
|
||||
{
|
||||
private bool _gameplayActive;
|
||||
private bool _commandsActive;
|
||||
|
||||
public void Activate()
|
||||
{
|
||||
if (_gameplayActive || _commandsActive)
|
||||
return;
|
||||
gameplay.Activate();
|
||||
_gameplayActive = true;
|
||||
try
|
||||
{
|
||||
commands.Activate();
|
||||
_commandsActive = true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
gameplay.Dispose();
|
||||
_gameplayActive = false;
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
List<Exception>? failures = null;
|
||||
if (_commandsActive)
|
||||
{
|
||||
try
|
||||
{
|
||||
commands.Dispose();
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
(failures ??= []).Add(error);
|
||||
}
|
||||
_commandsActive = false;
|
||||
}
|
||||
if (_gameplayActive)
|
||||
{
|
||||
try
|
||||
{
|
||||
gameplay.Dispose();
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
(failures ??= []).Add(error);
|
||||
}
|
||||
_gameplayActive = false;
|
||||
}
|
||||
if (failures is not null)
|
||||
{
|
||||
throw new AggregateException(
|
||||
"Headless command routes did not detach cleanly.",
|
||||
failures);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SessionCommandBridge : IRuntimeSessionCommands
|
||||
{
|
||||
private HeadlessSessionHost? _owner;
|
||||
|
|
@ -52,6 +116,7 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
private readonly IHeadlessBotPolicy _policy;
|
||||
private readonly IDisposable _policySubscription;
|
||||
private readonly LiveSessionHost _liveSession;
|
||||
private readonly RuntimeLocalPlayerFrameController _localPlayerFrame;
|
||||
private int _disposeStage;
|
||||
private long _reconnectDeadline;
|
||||
private bool _reconnectPending;
|
||||
|
|
@ -114,7 +179,9 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
new LiveSessionHostBindings(
|
||||
new LiveSessionRoutingFactories(
|
||||
CreateEventRoute,
|
||||
commands.CreateRoute),
|
||||
session => new SessionCommandRoute(
|
||||
gameplay.CreateRoute(session),
|
||||
commands.CreateRoute(session))),
|
||||
generation =>
|
||||
runtime.ResetGeneration(generation, _resetHost),
|
||||
new LiveSessionSelectionBindings(
|
||||
|
|
@ -143,6 +210,13 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
Runtime = runtime;
|
||||
Commands = commands;
|
||||
_liveSession = liveSession;
|
||||
_localPlayerFrame =
|
||||
runtime.CreateLocalPlayerFrameController(
|
||||
new HeadlessLocalPlayerFrameHost(
|
||||
runtime,
|
||||
liveSession),
|
||||
new HeadlessMovementInputSource(
|
||||
runtime.MovementOwner));
|
||||
bridge.Bind(this);
|
||||
|
||||
hostLease = runtime.AcquireHostLease(
|
||||
|
|
@ -194,7 +268,10 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
if (_reconnectPending)
|
||||
return;
|
||||
_ = Runtime.Clock.Advance(deltaSeconds);
|
||||
_localPlayerFrame.AdvanceBeforeNetwork(
|
||||
checked((float)deltaSeconds));
|
||||
Runtime.Session.Tick();
|
||||
_localPlayerFrame.RunPostNetworkCommandPhase();
|
||||
Runtime.ActionOwner.CombatAttack.Tick();
|
||||
_policy.Tick(Runtime, Commands);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}"));
|
||||
}
|
||||
|
||||
/// <summary>Monotonic sequence owner for one runtime instance.</summary>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
115
src/AcDream.Runtime/Gameplay/RuntimeHostileTargetQuery.cs
Normal file
115
src/AcDream.Runtime/Gameplay/RuntimeHostileTargetQuery.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
using System.Diagnostics;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.Core.Physics;
|
||||
|
||||
namespace AcDream.Runtime.Gameplay;
|
||||
|
|
@ -26,6 +28,8 @@ public sealed class RuntimeLocalPlayerMovementState
|
|||
private PlayerMovementController? _controller;
|
||||
private PlayerMovementController? _preparingMotionOwner;
|
||||
private bool _autoRunActive;
|
||||
private bool _hasCommandInput;
|
||||
private MovementInput _commandInput;
|
||||
private bool _disposed;
|
||||
private long _revision;
|
||||
|
||||
|
|
@ -43,6 +47,8 @@ public sealed class RuntimeLocalPlayerMovementState
|
|||
}
|
||||
|
||||
public bool AutoRunActive => _autoRunActive;
|
||||
public bool HasCommandInput => _hasCommandInput;
|
||||
public MovementInput CommandInput => _commandInput;
|
||||
public long Revision => Interlocked.Read(ref _revision);
|
||||
public IRuntimeMovementView View => this;
|
||||
|
||||
|
|
@ -63,7 +69,9 @@ public sealed class RuntimeLocalPlayerMovementState
|
|||
false,
|
||||
0d,
|
||||
Revision,
|
||||
_autoRunActive)
|
||||
_autoRunActive,
|
||||
_hasCommandInput,
|
||||
_commandInput)
|
||||
: new RuntimeMovementSnapshot(
|
||||
true,
|
||||
controller.LocalEntityId,
|
||||
|
|
@ -72,7 +80,9 @@ public sealed class RuntimeLocalPlayerMovementState
|
|||
controller.IsAirborne,
|
||||
controller.SimTimeSeconds,
|
||||
Revision,
|
||||
_autoRunActive);
|
||||
_autoRunActive,
|
||||
_hasCommandInput,
|
||||
_commandInput);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -109,7 +119,27 @@ public sealed class RuntimeLocalPlayerMovementState
|
|||
return true;
|
||||
case RuntimeMovementCommand.Stop:
|
||||
CancelAutoRun();
|
||||
ClearCommandInput();
|
||||
return true;
|
||||
case RuntimeMovementCommand.Ready:
|
||||
case RuntimeMovementCommand.Sit:
|
||||
case RuntimeMovementCommand.Crouch:
|
||||
case RuntimeMovementCommand.Sleep:
|
||||
CancelAutoRun();
|
||||
ClearCommandInput();
|
||||
uint motion = command switch
|
||||
{
|
||||
RuntimeMovementCommand.Ready =>
|
||||
MotionCommand.Ready,
|
||||
RuntimeMovementCommand.Sit =>
|
||||
MotionCommand.Sitting,
|
||||
RuntimeMovementCommand.Crouch =>
|
||||
MotionCommand.Crouch,
|
||||
RuntimeMovementCommand.Sleep =>
|
||||
MotionCommand.Sleeping,
|
||||
_ => throw new UnreachableException(),
|
||||
};
|
||||
return _controller?.RequestPosture(motion) == true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
|
@ -125,12 +155,75 @@ public sealed class RuntimeLocalPlayerMovementState
|
|||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Installs the persistent semantic movement level sampled by either host.
|
||||
/// The graphical input adapter and direct host both consume this exact
|
||||
/// owner; no bot-only movement latch exists.
|
||||
/// </summary>
|
||||
public void SetCommandInput(in MovementInput input)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (_hasCommandInput && _commandInput == input)
|
||||
return;
|
||||
_commandInput = input;
|
||||
_hasCommandInput = true;
|
||||
Interlocked.Increment(ref _revision);
|
||||
}
|
||||
|
||||
public bool ClearCommandInput()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (!_hasCommandInput)
|
||||
return false;
|
||||
_commandInput = default;
|
||||
_hasCommandInput = false;
|
||||
Interlocked.Increment(ref _revision);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Direct-host projection of the same combat readiness query used by the
|
||||
/// graphical attack adapter. A host without a constructed local movement
|
||||
/// owner is not ready; Slice K's content host must hydrate that owner
|
||||
/// before combat packets can leave.
|
||||
/// </summary>
|
||||
public bool IsReadyForAttack(CombatMode mode)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (_controller is not { } controller)
|
||||
return false;
|
||||
var motion = controller.Motion.InterpretedState;
|
||||
return CombatInputPlanner.PlayerInReadyPositionForAttack(
|
||||
mode,
|
||||
motion.CurrentStyle,
|
||||
motion.ForwardCommand);
|
||||
}
|
||||
|
||||
public bool IsDualWield
|
||||
{
|
||||
get
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
return _controller?.Motion.InterpretedState.CurrentStyle
|
||||
== CombatInputPlanner.DualWieldCombatStyle;
|
||||
}
|
||||
}
|
||||
|
||||
public bool PrepareForAttackRequest()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
CancelAutoRun();
|
||||
return _controller?.PrepareForAttackRequest() == true;
|
||||
}
|
||||
|
||||
public void ResetInputIntent()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (!_autoRunActive)
|
||||
if (!_autoRunActive && !_hasCommandInput)
|
||||
return;
|
||||
_autoRunActive = false;
|
||||
_hasCommandInput = false;
|
||||
_commandInput = default;
|
||||
Interlocked.Increment(ref _revision);
|
||||
}
|
||||
|
||||
|
|
@ -145,9 +238,12 @@ public sealed class RuntimeLocalPlayerMovementState
|
|||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
bool changed =
|
||||
_autoRunActive
|
||||
|| _hasCommandInput
|
||||
|| _controller is not null
|
||||
|| _preparingMotionOwner is not null;
|
||||
_autoRunActive = false;
|
||||
_hasCommandInput = false;
|
||||
_commandInput = default;
|
||||
_controller = null;
|
||||
_preparingMotionOwner = null;
|
||||
if (changed)
|
||||
|
|
@ -160,6 +256,7 @@ public sealed class RuntimeLocalPlayerMovementState
|
|||
_controller is not null,
|
||||
_preparingMotionOwner is not null,
|
||||
_autoRunActive,
|
||||
_hasCommandInput,
|
||||
Revision);
|
||||
|
||||
public void Dispose()
|
||||
|
|
@ -167,6 +264,8 @@ public sealed class RuntimeLocalPlayerMovementState
|
|||
if (_disposed)
|
||||
return;
|
||||
_autoRunActive = false;
|
||||
_hasCommandInput = false;
|
||||
_commandInput = default;
|
||||
_controller = null;
|
||||
_preparingMotionOwner = null;
|
||||
Interlocked.Increment(ref _revision);
|
||||
|
|
@ -210,11 +309,13 @@ public readonly record struct RuntimeLocalMovementOwnershipSnapshot(
|
|||
bool HasController,
|
||||
bool HasPreparingMotionOwner,
|
||||
bool AutoRunActive,
|
||||
bool HasCommandInput,
|
||||
long Revision)
|
||||
{
|
||||
public bool IsConverged =>
|
||||
IsDisposed
|
||||
&& !HasController
|
||||
&& !HasPreparingMotionOwner
|
||||
&& !AutoRunActive;
|
||||
&& !AutoRunActive
|
||||
&& !HasCommandInput;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ public readonly record struct RuntimeCollisionAcknowledgement(
|
|||
/// </summary>
|
||||
public sealed class RuntimePhysicsState : IDisposable
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly Dictionary<RuntimeEntityKey, RuntimeEntityRecord>
|
||||
_spatialRoots = new();
|
||||
private readonly Dictionary<RuntimeEntityKey, IRuntimeRemoteMotion>
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -270,6 +270,60 @@ public sealed class RetailLocalPlayerFrameControllerTests
|
|||
Assert.Equal(10, projections);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GraphicalWrapperAndDirectRuntimeFrameProduceIdenticalTrace()
|
||||
{
|
||||
List<string> Run(bool graphical)
|
||||
{
|
||||
PlayerMovementController controller = CreateController();
|
||||
var trace = new List<string>();
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
using AcDream.App.Spells;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Spells;
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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<WorldSession> 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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<ClientObject> 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() { }
|
||||
}
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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<RuntimeEventStamp> Entity { get; } = [];
|
||||
public List<RuntimeEventStamp> Inventory { get; } = [];
|
||||
public List<RuntimeEventStamp> 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 :
|
||||
|
|
|
|||
|
|
@ -67,6 +67,12 @@ public sealed class DirectGameRuntimeCommandAdapterTests
|
|||
var gameActions = new List<byte[]>();
|
||||
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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue