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

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