feat(headless): complete deterministic bot command parity

This commit is contained in:
Erik 2026-07-27 08:23:36 +02:00
parent 7e8acb74dd
commit 38e83640d9
37 changed files with 2805 additions and 295 deletions

View file

@ -686,6 +686,7 @@ internal sealed class SessionPlayerCompositionPhase
d.PlayerOutbound,
liveSessionSource);
var localPlayerFrame = new RetailLocalPlayerFrameController(
d.Runtime,
localPlayerFrameRuntime,
d.MovementInput);
var liveObjectFrame = new LiveObjectFrameController(

View file

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

View file

@ -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>

View file

@ -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,
};
}

View file

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

View file

@ -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>

View file

@ -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}.");

View file

@ -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;
}

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

View file

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

View file

@ -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()
{
}

View file

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

View file

@ -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) =>

View file

@ -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

View file

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

View file

@ -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>

View file

@ -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
{

View file

@ -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

View file

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

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

View file

@ -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,
};
}

View file

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

View file

@ -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(

View file

@ -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

View file

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

View file

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

View file

@ -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

View file

@ -1,4 +1,3 @@
using AcDream.App.Spells;
using AcDream.Core.Items;
using AcDream.Core.Spells;

View file

@ -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"

View file

@ -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(

View file

@ -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

View file

@ -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() { }
}
}

View file

@ -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]

View file

@ -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(

View file

@ -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()
{

View file

@ -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 :

View file

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