392 lines
12 KiB
C#
392 lines
12 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;
|
|
|
|
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 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);
|
|
}
|
|
|
|
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;
|
|
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; }
|
|
|
|
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)
|
|
{
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
}
|