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

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