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

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