acdream/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs

604 lines
20 KiB
C#

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;
using AcDream.Runtime.World;
namespace AcDream.Headless.Tests;
public sealed class HeadlessSessionHostTests
{
[Fact]
public void SingleSessionStartsReconnectsAndConvergesWithoutPresentation()
{
var operations = new FixtureSessionOperations();
using var diagnosticsOutput = new StringWriter();
using var credential = new HeadlessCredentialSecret(
"fixture",
"password");
var host = new HeadlessSessionHost(
Descriptor(),
credential,
new HeadlessDiagnosticWriter(diagnosticsOutput),
operations);
RuntimeSessionStartResult first = host.Start();
ulong firstGeneration = host.Runtime.Generation.Value;
host.Tick(0.015d);
RuntimeSessionStartResult second = host.Reconnect();
Assert.Equal(RuntimeSessionStartStatus.Connected, first.Status);
Assert.Equal(RuntimeSessionStartStatus.Connected, second.Status);
Assert.Equal(0x50000002u, first.CharacterId);
Assert.Equal("Headless", host.ActiveCharacterName);
Assert.True(host.Runtime.Session.IsInWorld);
Assert.True(host.Runtime.Generation.Value > firstGeneration);
Assert.Equal(2, operations.CreatedSessionCount);
Assert.Equal(1, operations.DisposedSessionCount);
host.Dispose();
Assert.Equal(2, operations.DisposedSessionCount);
Assert.True(host.Runtime.CaptureOwnership().IsConverged);
Assert.True(credential.IsDisposed);
string diagnostics = diagnosticsOutput.ToString();
Assert.Contains("\"state\":\"start-result\"", diagnostics);
Assert.DoesNotContain("password", diagnostics);
Assert.DoesNotContain("AcDream.App", diagnostics);
}
[Fact]
public async Task ProcessHostRunsUntilCancellationAndReturnsStableExitCode()
{
var configuration = new HeadlessConfiguration
{
Version = 1,
Sessions =
[
Descriptor(
HeadlessCredentialProviderKind.StandardInput,
"stdin-bot"),
],
};
HeadlessPathSet paths = HeadlessPathSet.Resolve(
new HeadlessPathOverrides());
using var diagnostics = new StringWriter();
var operations = new FixtureSessionOperations();
using var host = new HeadlessProcessHost(
configuration,
paths,
new System.IO.StringReader(
"process-password" + Environment.NewLine),
diagnostics,
operations);
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
HeadlessExitCode result =
await host.RunAsync(cancellation.Token);
Assert.Equal(HeadlessExitCode.Success, result);
Assert.True(host.Session.Runtime.Session.IsInWorld);
Assert.DoesNotContain(
"process-password",
diagnostics.ToString());
}
[Fact]
public async Task DirectCredentialsOverrideSingleConfiguredSession()
{
var configuration = new HeadlessConfiguration
{
Version = 1,
Sessions = [Descriptor()],
};
HeadlessPathSet paths = HeadlessPathSet.Resolve(
new HeadlessPathOverrides());
using var diagnostics = new StringWriter();
var operations = new FixtureSessionOperations();
using var host = new HeadlessProcessHost(
configuration,
paths,
TextReader.Null,
diagnostics,
operations,
directCredentials: new HeadlessDirectCredentials(
"direct-account",
"direct-secret"));
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
HeadlessExitCode result =
await host.RunAsync(cancellation.Token);
Assert.Equal(HeadlessExitCode.Success, result);
Assert.Equal("direct-account", operations.LastUser);
Assert.Equal("direct-secret", operations.LastPassword);
Assert.DoesNotContain(
"direct-secret",
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()
{
var operations = new FixtureSessionOperations();
var writer = new FailOnceTextWriter();
using var credential = new HeadlessCredentialSecret(
"fixture",
"password");
var host = new HeadlessSessionHost(
Descriptor(),
credential,
new HeadlessDiagnosticWriter(writer),
operations);
Assert.Equal(
RuntimeSessionStartStatus.Connected,
host.Start().Status);
writer.FailNextWrite = true;
Assert.Throws<IOException>(host.Dispose);
Assert.Equal(1, operations.DisposedSessionCount);
host.Dispose();
Assert.Equal(1, operations.DisposedSessionCount);
Assert.True(host.Runtime.CaptureOwnership().IsConverged);
}
[Fact]
public void WorldProjectionHydratesCanonicalMovementAndTeleportState()
{
var operations = new FixtureSessionOperations();
using var credential = new HeadlessCredentialSecret(
"fixture",
"password");
using var host = new HeadlessSessionHost(
Descriptor(),
credential,
new HeadlessDiagnosticWriter(TextWriter.Null),
operations);
GameRuntime runtime = host.Runtime;
const uint player = 0x50000002u;
runtime.PlayerIdentity.ServerGuid = player;
AddFlatLandblock(runtime.EntityObjects.Physics.Engine);
RuntimeEntityRecord record = runtime.EntityObjects
.RegisterEntity(Spawn(player))
.Canonical!;
Assert.True(runtime.EntityObjects.ApplyAcceptedSpawn(
record,
record.CreateIntegrationVersion,
record.Snapshot,
replaceGeneration: false));
var collision = new FixtureCollisionNeighborhood();
var projection = new HeadlessSessionWorldProjection(
runtime,
collision);
projection.ProjectSpawn(record, isLocalPlayer: true);
PlayerMovementController controller =
Assert.IsType<PlayerMovementController>(
runtime.MovementOwner.Controller);
controller.SetPosition(
new Vector3(48f, 49f, 50f),
0xA9B40001u);
projection.ProjectPosition(
record,
isLocalPlayer: true,
PositionTimestampDisposition.Apply);
Assert.Same(controller, runtime.MovementOwner.Controller);
Assert.Equal(record.LocalEntityId, controller.LocalEntityId);
Assert.Equal(new Vector3(48f, 49f, 50f), controller.Position);
Assert.Equal(
0xA9B40000u,
controller.CellId & 0xFFFF0000u);
Assert.True((controller.CellId & 0xFFFFu) < 0x0100u);
projection.BeginTeleport();
Assert.Equal(PlayerState.PortalSpace, controller.State);
RuntimeDestinationReadiness readiness =
projection.PrepareDestination(
revealGeneration: 7,
new RuntimeTeleportDestination(
player,
InstanceSequence: 1,
PositionSequence: 2,
TeleportSequence: 1,
ForcePositionSequence: 0,
new Position(
0xA9B40001u,
new Vector3(96f, 97f, 50f),
Quaternion.Identity)));
Assert.True(readiness.IsCollisionReady);
Assert.False(readiness.IsUnhydratable);
Assert.Equal(PlayerState.InWorld, controller.State);
Assert.Equal(3, collision.CenterCount);
Assert.Equal(0xA9B40001u, collision.LastCell);
}
[Fact]
public void WorldProjectionIgnoresNormalEchoButBlipsForcePosition()
{
var operations = new FixtureSessionOperations();
using var credential = new HeadlessCredentialSecret(
"fixture",
"password");
using var host = new HeadlessSessionHost(
Descriptor(),
credential,
new HeadlessDiagnosticWriter(TextWriter.Null),
operations);
GameRuntime runtime = host.Runtime;
const uint player = 0x50000003u;
runtime.PlayerIdentity.ServerGuid = player;
AddFlatLandblock(runtime.EntityObjects.Physics.Engine);
RuntimeEntityRecord record = runtime.EntityObjects
.RegisterEntity(Spawn(player))
.Canonical!;
Assert.True(runtime.EntityObjects.ApplyAcceptedSpawn(
record,
record.CreateIntegrationVersion,
record.Snapshot,
replaceGeneration: false));
var collision = new FixtureCollisionNeighborhood();
var projection = new HeadlessSessionWorldProjection(
runtime,
collision);
projection.ProjectSpawn(record, isLocalPlayer: true);
PlayerMovementController controller =
Assert.IsType<PlayerMovementController>(
runtime.MovementOwner.Controller);
controller.SetPosition(
new Vector3(48f, 49f, 50f),
0xA9B40001u);
projection.ProjectPosition(
record,
isLocalPlayer: true,
PositionTimestampDisposition.Apply);
Assert.Equal(new Vector3(48f, 49f, 50f), controller.Position);
var force = new WorldSession.EntityPositionUpdate(
player,
record.Snapshot.Position!.Value with
{
PositionX = 72f,
PositionY = 73f,
},
Velocity: null,
PlacementId: null,
IsGrounded: true,
InstanceSequence: 1,
PositionSequence: 2,
TeleportSequence: 0,
ForcePositionSequence: 1);
Assert.True(runtime.EntityObjects.TryApplyPosition(
force,
isLocalPlayer: true,
forcePositionRotation: Quaternion.Identity,
currentLocalVelocity: controller.BodyVelocity,
projectionRequiresTeleportHook: false,
acknowledgeProjection: null,
out PositionTimestampDisposition disposition,
out _,
out _));
Assert.Equal(
PositionTimestampDisposition.ForcePosition,
disposition);
projection.ProjectPosition(
record,
isLocalPlayer: true,
disposition);
Assert.Equal(new Vector3(72f, 73f, 50f), controller.Position);
Assert.Equal(2, collision.CenterCount);
}
private static HeadlessSessionDescriptor Descriptor(
HeadlessCredentialProviderKind provider =
HeadlessCredentialProviderKind.Environment,
string credentialReference = "BOT_PASSWORD") => new()
{
Id = "bot",
Endpoint = new HeadlessEndpointDescriptor
{
Host = "127.0.0.1",
Port = 9000,
},
Account = "account",
Character = new HeadlessCharacterSelector
{
Name = "headless",
},
Policy = new HeadlessBotPolicyDescriptor
{
Id = "idle",
},
Credential = new HeadlessCredentialReference
{
Provider = provider,
Reference = credentialReference,
},
};
private static void HydrateGroundedPlayer(GameRuntime runtime)
{
const uint player = 0x50000002u;
PhysicsEngine engine = runtime.EntityObjects.Physics.Engine;
AddFlatLandblock(engine);
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 void AddFlatLandblock(PhysicsEngine 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);
}
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; }
public string? LastUser { get; private set; }
public string? LastPassword { get; private set; }
public IPEndPoint ResolveEndpoint(string host, int port) =>
new(IPAddress.Loopback, port);
public WorldSession CreateSession(IPEndPoint endpoint)
{
CreatedSessionCount++;
var session = new WorldSession(endpoint);
Sessions.Add(session);
return session;
}
public void Connect(
WorldSession session,
string user,
string password)
{
LastUser = user;
LastPassword = password;
}
public CharacterList.Parsed GetCharacters(
WorldSession session) =>
new(
0u,
[
new CharacterList.Character(
0x50000001u,
"Other",
0u),
new CharacterList.Character(
0x50000002u,
"Headless",
0u),
],
[],
11,
"account",
true,
true);
public void EnterWorld(
WorldSession session,
int activeCharacterIndex)
{
}
public void Tick(WorldSession session)
{
}
public void DisposeSession(WorldSession session)
{
DisposedSessionCount++;
session.Dispose();
}
}
private sealed class FailOnceTextWriter : StringWriter
{
public bool FailNextWrite { get; set; }
public override void WriteLine(string? value)
{
if (FailNextWrite)
{
FailNextWrite = false;
throw new IOException("fixture write failure");
}
base.WriteLine(value);
}
}
private sealed class FixtureCollisionNeighborhood
: IHeadlessCollisionNeighborhood
{
public int CenterCount { get; private set; }
public uint LastCell { get; private set; }
public void CenterOn(uint fullCellId)
{
CenterCount++;
LastCell = fullCellId;
}
public bool IsReady(uint fullCellId) =>
fullCellId == LastCell;
}
}