fix(headless): complete connected movement gate

This commit is contained in:
Erik 2026-07-27 10:26:44 +02:00
parent fd9559a063
commit 3f3401257c
11 changed files with 756 additions and 48 deletions

View file

@ -55,6 +55,12 @@ public sealed class PhysicsEngine
/// <summary>Number of registered landblocks (diagnostic).</summary> /// <summary>Number of registered landblocks (diagnostic).</summary>
public int LandblockCount => _landblocks.Count; public int LandblockCount => _landblocks.Count;
/// <summary>
/// Optional high-volume collision trace sink. Production leaves this
/// unset; focused diagnostic gates may opt in explicitly.
/// </summary>
public Action<string>? DiagnosticLog { get; set; }
/// <summary> /// <summary>
/// True once the landblock covering <paramref name="cellOrLandblockId"/> has had its /// True once the landblock covering <paramref name="cellOrLandblockId"/> has had its
/// terrain + cells registered via <see cref="AddLandblock"/>. Accepts a canonical /// terrain + cells registered via <see cref="AddLandblock"/>. Accepts a canonical
@ -797,8 +803,8 @@ public sealed class PhysicsEngine
if (physics is null) if (physics is null)
{ {
if (snapDiag) if (snapDiag && DiagnosticLog is { } noLandblockLog)
Console.WriteLine(System.FormattableString.Invariant( noLandblockLog(System.FormattableString.Invariant(
$"[snap] claim=0x{cellId:X8} pos=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) branch=NO-LANDBLOCK (lbs={_landblocks.Count}) -> verbatim")); $"[snap] claim=0x{cellId:X8} pos=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) branch=NO-LANDBLOCK (lbs={_landblocks.Count}) -> verbatim"));
return new ResolveResult(candidatePos, cellId, IsOnGround: false); return new ResolveResult(candidatePos, cellId, IsOnGround: false);
} }
@ -831,8 +837,11 @@ public sealed class PhysicsEngine
float? claimFloorZ = WalkableFloorZNearest(cellId, candidatePos, currentPos.Z); float? claimFloorZ = WalkableFloorZNearest(cellId, candidatePos, currentPos.Z);
if (claimFloorZ is not null) if (claimFloorZ is not null)
{ {
Console.WriteLine(System.FormattableString.Invariant( if (DiagnosticLog is { } validatedLog)
$"[snap] claim=0x{cellId:X8} pos=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) VALIDATED -> grounded to its walkable floor z={claimFloorZ.Value:F3}")); {
validatedLog(System.FormattableString.Invariant(
$"[snap] claim=0x{cellId:X8} pos=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) VALIDATED -> grounded to its walkable floor z={claimFloorZ.Value:F3}"));
}
// #133 (2026-06-13): return the VALIDATED claim's OWN full cell id, // #133 (2026-06-13): return the VALIDATED claim's OWN full cell id,
// NOT lbPrefix | (cellId & 0xFFFF). lbPrefix is found by scanning // NOT lbPrefix | (cellId & 0xFFFF). lbPrefix is found by scanning
// resident landblocks for one whose [0,192) local bounds contain // resident landblocks for one whose [0,192) local bounds contain
@ -1017,10 +1026,14 @@ public sealed class PhysicsEngine
// max(terrain, z) stays as the under-terrain sanity bound — // max(terrain, z) stays as the under-terrain sanity bound —
// our recoverable stand-in for retail's lost-cell machinery // our recoverable stand-in for retail's lost-cell machinery
// (documented divergence, same as the #107 demote). // (documented divergence, same as the #107 demote).
if (snapDiag && currentPos.Z > terrainZ) if (snapDiag
&& currentPos.Z > terrainZ)
{ {
Console.WriteLine(System.FormattableString.Invariant( if (DiagnosticLog is { } outdoorLog)
{
outdoorLog(System.FormattableString.Invariant(
$"[snap] OUTDOOR claim 0x{cellId:X8} z={currentPos.Z:F3} above terrain {terrainZ:F3} — committing the server Z (retail SetPositionInternal shape; physics settles on tick 1)")); $"[snap] OUTDOOR claim 0x{cellId:X8} z={currentPos.Z:F3} above terrain {terrainZ:F3} — committing the server Z (retail SetPositionInternal shape; physics settles on tick 1)"));
}
targetZ = currentPos.Z; targetZ = currentPos.Z;
} }
} }
@ -1028,8 +1041,8 @@ public sealed class PhysicsEngine
// Step-height enforcement: block upward movement that exceeds the limit. // Step-height enforcement: block upward movement that exceeds the limit.
float zDelta = targetZ - currentPos.Z; float zDelta = targetZ - currentPos.Z;
if (snapDiag) if (snapDiag && DiagnosticLog is { } resultLog)
Console.WriteLine(System.FormattableString.Invariant( resultLog(System.FormattableString.Invariant(
$"[snap] claim=0x{cellId:X8} pos=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) cells={physics.Cells.Count} bestCell=0x{(bestCell?.CellId ?? 0u):X8} bestZ={(bestCellZ?.ToString("F3") ?? "none")} terrainZ={terrainZ:F3} indoor={currentlyIndoor} -> targetZ={targetZ:F3} targetCell=0x{(lbPrefix | (targetCellId & 0xFFFFu)):X8} stepReject={zDelta > stepUpHeight}")); $"[snap] claim=0x{cellId:X8} pos=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) cells={physics.Cells.Count} bestCell=0x{(bestCell?.CellId ?? 0u):X8} bestZ={(bestCellZ?.ToString("F3") ?? "none")} terrainZ={terrainZ:F3} indoor={currentlyIndoor} -> targetZ={targetZ:F3} targetCell=0x{(lbPrefix | (targetCellId & 0xFFFFu)):X8} stepReject={zDelta > stepUpHeight}"));
if (zDelta > stepUpHeight) if (zDelta > stepUpHeight)
{ {

View file

@ -3,7 +3,8 @@ namespace AcDream.Headless.Configuration;
internal sealed record HeadlessCommandLine( internal sealed record HeadlessCommandLine(
string Command, string Command,
string ConfigurationPath, string ConfigurationPath,
HeadlessPathOverrides Paths) HeadlessPathOverrides Paths,
HeadlessDirectCredentials? DirectCredentials)
{ {
internal static HeadlessCommandLine Parse( internal static HeadlessCommandLine Parse(
IReadOnlyList<string> arguments) IReadOnlyList<string> arguments)
@ -20,6 +21,8 @@ internal sealed record HeadlessCommandLine(
string? configDirectory = null; string? configDirectory = null;
string? dataDirectory = null; string? dataDirectory = null;
string? cacheDirectory = null; string? cacheDirectory = null;
string? user = null;
string? password = null;
for (int index = 1; index < arguments.Count; index += 2) for (int index = 1; index < arguments.Count; index += 2)
{ {
if (index + 1 >= arguments.Count) if (index + 1 >= arguments.Count)
@ -50,6 +53,14 @@ internal sealed record HeadlessCommandLine(
case "--cache-dir": case "--cache-dir":
SetOnce(ref cacheDirectory, value); SetOnce(ref cacheDirectory, value);
break; break;
case "-user":
case "--user":
SetOnce(ref user, value);
break;
case "-password":
case "--password":
SetOnce(ref password, value);
break;
default: default:
throw new HeadlessCommandLineException( throw new HeadlessCommandLineException(
"Unknown command option."); "Unknown command option.");
@ -61,6 +72,16 @@ internal sealed record HeadlessCommandLine(
throw new HeadlessCommandLineException( throw new HeadlessCommandLineException(
"The --config option is required."); "The --config option is required.");
} }
if ((user is null) != (password is null))
{
throw new HeadlessCommandLineException(
"The user and password options must be supplied together.");
}
if (user is not null && command != "run")
{
throw new HeadlessCommandLineException(
"Direct credentials are valid only for run mode.");
}
return new HeadlessCommandLine( return new HeadlessCommandLine(
command, command,
@ -68,7 +89,10 @@ internal sealed record HeadlessCommandLine(
new HeadlessPathOverrides( new HeadlessPathOverrides(
configDirectory, configDirectory,
dataDirectory, dataDirectory,
cacheDirectory)); cacheDirectory),
user is null
? null
: new HeadlessDirectCredentials(user, password!));
} }
private static void SetOnce(ref string? destination, string value) private static void SetOnce(ref string? destination, string value)
@ -81,3 +105,7 @@ internal sealed record HeadlessCommandLine(
destination = value; destination = value;
} }
} }
internal sealed record HeadlessDirectCredentials(
string User,
string Password);

View file

@ -14,7 +14,7 @@ internal static class HeadlessEntryPoint
Usage: Usage:
acdream-headless --help acdream-headless --help
acdream-headless validate --config <path> [path overrides] acdream-headless validate --config <path> [path overrides]
acdream-headless run --config <path> [path overrides] acdream-headless run --config <path> [path overrides] [credentials]
Commands: Commands:
validate Validate a versioned headless configuration without connecting. validate Validate a versioned headless configuration without connecting.
@ -25,7 +25,9 @@ internal static class HeadlessEntryPoint
--data-dir <path> --data-dir <path>
--cache-dir <path> --cache-dir <path>
Secrets are never accepted on the command line. Direct single-session credentials:
-user <account> -password <password>
--user <account> --password <password>
"""; """;
internal static int Run( internal static int Run(
@ -76,7 +78,9 @@ internal static class HeadlessEntryPoint
configuration, configuration,
paths, paths,
standardInput, standardInput,
output); output,
directCredentials:
commandLine.DirectCredentials);
return (int)host.RunAsync(cancellationToken) return (int)host.RunAsync(cancellationToken)
.GetAwaiter() .GetAwaiter()
.GetResult(); .GetResult();

View file

@ -23,7 +23,8 @@ internal sealed class HeadlessProcessHost : IDisposable
TextWriter diagnostics, TextWriter diagnostics,
ILiveSessionOperations? sessionOperations = null, ILiveSessionOperations? sessionOperations = null,
TimeProvider? timeProvider = null, TimeProvider? timeProvider = null,
IHeadlessProcessContentFactory? contentFactory = null) IHeadlessProcessContentFactory? contentFactory = null,
HeadlessDirectCredentials? directCredentials = null)
{ {
ArgumentNullException.ThrowIfNull(configuration); ArgumentNullException.ThrowIfNull(configuration);
ArgumentNullException.ThrowIfNull(paths); ArgumentNullException.ThrowIfNull(paths);
@ -34,6 +35,12 @@ internal sealed class HeadlessProcessHost : IDisposable
throw new HeadlessConfigurationException( throw new HeadlessConfigurationException(
"Headless run mode requires at least one session."); "Headless run mode requires at least one session.");
} }
if (directCredentials is not null
&& configuration.Sessions.Count != 1)
{
throw new HeadlessConfigurationException(
"Direct credentials require exactly one configured session.");
}
HeadlessStaticStateAudit.ValidateProcessIsolation(); HeadlessStaticStateAudit.ValidateProcessIsolation();
_diagnostics = new HeadlessDiagnosticWriter(diagnostics); _diagnostics = new HeadlessDiagnosticWriter(diagnostics);
@ -61,10 +68,19 @@ internal sealed class HeadlessProcessHost : IDisposable
HeadlessSessionDescriptor descriptor = candidate HeadlessSessionDescriptor descriptor = candidate
?? throw new HeadlessConfigurationException( ?? throw new HeadlessConfigurationException(
"Headless run mode cannot contain a null session."); "Headless run mode cannot contain a null session.");
HeadlessCredentialSecret secret = if (directCredentials is not null)
credentials.Resolve( {
descriptor = WithAccount(
descriptor,
directCredentials.User);
}
HeadlessCredentialSecret secret = directCredentials is null
? credentials.Resolve(
descriptor.Id, descriptor.Id,
descriptor.Credential); descriptor.Credential)
: new HeadlessCredentialSecret(
"command-line",
directCredentials.Password);
HeadlessProcessContentOwner.HeadlessProcessContentLease? HeadlessProcessContentOwner.HeadlessProcessContentLease?
contentLease = content?.AcquireLease(descriptor.Id); contentLease = content?.AcquireLease(descriptor.Id);
try try
@ -111,6 +127,19 @@ internal sealed class HeadlessProcessHost : IDisposable
internal HeadlessProcessContentSnapshot? Content => internal HeadlessProcessContentSnapshot? Content =>
_content?.CaptureSnapshot(); _content?.CaptureSnapshot();
private static HeadlessSessionDescriptor WithAccount(
HeadlessSessionDescriptor source,
string account) =>
new()
{
Id = source.Id,
Endpoint = source.Endpoint,
Account = account,
Character = source.Character,
Policy = source.Policy,
Credential = source.Credential,
};
internal async Task<HeadlessExitCode> RunAsync( internal async Task<HeadlessExitCode> RunAsync(
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {

View file

@ -293,10 +293,20 @@ internal sealed class HeadlessSessionWorldProjection
public void ProjectPosition( public void ProjectPosition(
RuntimeEntityRecord record, RuntimeEntityRecord record,
bool isLocalPlayer) bool isLocalPlayer,
PositionTimestampDisposition disposition)
{ {
if (isLocalPlayer) if (!isLocalPlayer)
return;
if (_runtime.MovementOwner.Controller is null)
{
SynchronizeLocalPlayer(record); SynchronizeLocalPlayer(record);
return;
}
if (disposition is PositionTimestampDisposition.ForcePosition)
BlipLocalPlayer(record);
} }
public void BeginTeleport() public void BeginTeleport()
@ -383,6 +393,28 @@ internal sealed class HeadlessSessionWorldProjection
controller.SetBodyOrientation(orientation); controller.SetBodyOrientation(orientation);
} }
private void BlipLocalPlayer(RuntimeEntityRecord record)
{
if (record.ServerGuid
!= _runtime.PlayerIdentity.ServerGuid
|| record.Snapshot.Position is not { } position
|| _runtime.MovementOwner.Controller is not { } controller)
{
return;
}
_collision.CenterOn(position.LandblockId);
Vector3 wirePosition = new(
position.PositionX,
position.PositionY,
position.PositionZ);
controller.LocalEntityId = record.LocalEntityId ?? 0u;
controller.BlipPosition(
wirePosition,
position.LandblockId,
wirePosition);
}
private PlayerMovementController CreateController( private PlayerMovementController CreateController(
RuntimeEntityRecord record) RuntimeEntityRecord record)
{ {

View file

@ -1,5 +1,6 @@
using AcDream.Headless.Configuration; using AcDream.Headless.Configuration;
using AcDream.Runtime; using AcDream.Runtime;
using AcDream.Runtime.Gameplay;
namespace AcDream.Headless.Policies; namespace AcDream.Headless.Policies;
@ -17,6 +18,8 @@ internal static class HeadlessBotPolicyFactory
{ {
"idle" => new IdleHeadlessBotPolicy(), "idle" => new IdleHeadlessBotPolicy(),
"lifecycle-smoke" => new LifecycleSmokeHeadlessBotPolicy(), "lifecycle-smoke" => new LifecycleSmokeHeadlessBotPolicy(),
"observer-movement" => new ObserverMovementHeadlessBotPolicy(),
"portal-route-smoke" => new PortalRouteSmokeHeadlessBotPolicy(),
_ => throw new HeadlessConfigurationException( _ => throw new HeadlessConfigurationException(
$"Unknown headless bot policy '{id}'."), $"Unknown headless bot policy '{id}'."),
}; };
@ -189,3 +192,384 @@ internal sealed class LifecycleSmokeHeadlessBotPolicy
} }
} }
} }
/// <summary>
/// Long-lived connected observer gate. It materializes at the canonical
/// Rynthid checkpoint, announces itself, and alternates run/turn intents
/// through the same typed Runtime command API used by graphical hosts. The
/// process remains connected until externally cancelled so a second client
/// has an unambiguous observation window.
/// </summary>
internal sealed class ObserverMovementHeadlessBotPolicy
: IHeadlessBotPolicy
{
private const uint RynthidCell = 0xF6820033u;
private int _stage;
private long _issuedAfterPortalGeneration;
private double _movementDeadline;
private bool _turning;
public bool IsComplete => false;
public void Tick(
IGameRuntimeView view,
IGameRuntimeCommands commands)
{
ArgumentNullException.ThrowIfNull(view);
ArgumentNullException.ThrowIfNull(commands);
if (!HasLocalPlayer(view))
return;
if (_stage == 0)
{
_issuedAfterPortalGeneration =
view.Portal.Snapshot.Generation;
Require(commands.Chat.Execute(
view.Generation,
new RuntimeChatCommand(
RuntimeChatChannel.Say,
"@teleloc 0xF6820033 145.7 49.855 58.010 1 0 0 0")));
_stage = 1;
return;
}
if (_stage == 1)
{
RuntimePortalSnapshot portal = view.Portal.Snapshot;
if (portal.Generation <= _issuedAfterPortalGeneration
|| portal.Kind is not RuntimePortalKind.Portal
|| !portal.Completed)
{
return;
}
if (portal.DestinationCell != RynthidCell)
{
throw new InvalidOperationException(
$"Observer gate completed at "
+ $"0x{portal.DestinationCell:X8}; expected "
+ $"0x{RynthidCell:X8}.");
}
Require(commands.Chat.Execute(
view.Generation,
new RuntimeChatCommand(
RuntimeChatChannel.Say,
"[acdream headless observer active]")));
StartMovement(view, commands);
_stage = 2;
return;
}
if (view.Clock.SimulationTimeSeconds
< _movementDeadline)
{
return;
}
StartMovement(view, commands);
}
public void OnLifecycle(in RuntimeLifecycleDelta delta)
{
}
public void OnCommand(in RuntimeCommandDelta delta)
{
}
public void OnEntity(in RuntimeEntityDelta delta)
{
}
public void OnInventory(in RuntimeInventoryDelta delta)
{
}
public void OnChat(in RuntimeChatDelta delta)
{
}
public void OnMovement(in RuntimeMovementDelta delta)
{
}
public void OnPortal(in RuntimePortalDelta delta)
{
}
public void OnCombat(in RuntimeCombatDelta delta)
{
}
public void Dispose()
{
}
private void StartMovement(
IGameRuntimeView view,
IGameRuntimeCommands commands)
{
_turning = !_turning;
MovementInput input = _turning
? new MovementInput(TurnRight: true)
: new MovementInput(Forward: true, Run: true);
Require(commands.Movement.SetIntent(
view.Generation,
input));
_movementDeadline =
view.Clock.SimulationTimeSeconds
+ (_turning ? 1.25 : 4.0);
}
private static bool HasLocalPlayer(
IGameRuntimeView view) =>
view.Lifecycle.State
is RuntimeLifecycleState.InWorld
&& view.Lifecycle.PlayerGuid != 0u
&& view.Entities.TryGet(
view.Lifecycle.PlayerGuid,
out _);
private static void Require(
in RuntimeCommandResult result)
{
if (!result.Accepted)
{
throw new InvalidOperationException(
$"Observer gate command was rejected with "
+ $"{result.Status}.");
}
}
}
/// <summary>
/// Explicit connected portal-route gate. It visits the same named locations
/// used by the canonical graphical soak, then performs a typed lifestone
/// recall. A later command is never issued until the preceding portal has
/// produced a new completed Runtime portal generation.
/// </summary>
internal sealed class PortalRouteSmokeHeadlessBotPolicy
: IHeadlessBotPolicy
{
private const uint AerlintheCell = 0x09040008u;
private const uint CaulCell = 0xC95B0001u;
private const uint RynthidCell = 0xF6820033u;
private static readonly RouteStop[] Stops =
[
new(
"aerlinthe",
"@teleloc 0x09040008 11.4 188.6 87.705 1 0 0 0",
AerlintheCell),
new(
"caul",
"@teleloc 0xC95B0001 14.8 0.3 12.005 1 0 0 0",
CaulCell),
new(
"rynthid",
"@teleloc 0xF6820033 145.7 49.855 58.010 1 0 0 0",
RynthidCell),
new("lifestone", null, null),
];
private int _nextStop;
private bool _awaitingPortal;
private long _issuedAfterPortalGeneration;
private RouteMovementStage _movementStage;
private double _movementDeadline;
public bool IsComplete =>
_nextStop == Stops.Length
&& !_awaitingPortal
&& _movementStage is RouteMovementStage.None;
public void Tick(
IGameRuntimeView view,
IGameRuntimeCommands commands)
{
ArgumentNullException.ThrowIfNull(view);
ArgumentNullException.ThrowIfNull(commands);
if (!HasLocalPlayer(view))
return;
if (_awaitingPortal)
{
RuntimePortalSnapshot portal = view.Portal.Snapshot;
if (portal.Generation <= _issuedAfterPortalGeneration
|| portal.Kind is not RuntimePortalKind.Portal
|| !portal.Completed)
{
return;
}
RouteStop completed = Stops[_nextStop];
if (completed.ExpectedCell is uint expectedCell
&& portal.DestinationCell != expectedCell)
{
throw new InvalidOperationException(
$"Portal route '{completed.Name}' completed at "
+ $"0x{portal.DestinationCell:X8}; expected "
+ $"0x{expectedCell:X8}.");
}
_awaitingPortal = false;
StartMovement(
view,
commands,
RouteMovementStage.ForwardOne);
return;
}
if (_movementStage is not RouteMovementStage.None)
{
TickMovement(view, commands);
return;
}
if (_nextStop == Stops.Length)
return;
RouteStop stop = Stops[_nextStop];
_issuedAfterPortalGeneration = view.Portal.Snapshot.Generation;
RuntimeCommandResult result = stop.ServerCommand is string text
? commands.Chat.Execute(
view.Generation,
new RuntimeChatCommand(RuntimeChatChannel.Say, text))
: commands.Portal.Execute(
view.Generation,
RuntimePortalCommand.RecallLifestone);
Require(result, stop.Name);
_awaitingPortal = true;
}
private void TickMovement(
IGameRuntimeView view,
IGameRuntimeCommands commands)
{
if (view.Clock.SimulationTimeSeconds < _movementDeadline)
return;
switch (_movementStage)
{
case RouteMovementStage.ForwardOne:
StartMovement(
view,
commands,
RouteMovementStage.TurnRight);
break;
case RouteMovementStage.TurnRight:
StartMovement(
view,
commands,
RouteMovementStage.ForwardTwo);
break;
case RouteMovementStage.ForwardTwo:
Require(
commands.Movement.ClearIntent(view.Generation),
Stops[_nextStop].Name);
_movementStage = RouteMovementStage.None;
_nextStop++;
break;
}
}
private void StartMovement(
IGameRuntimeView view,
IGameRuntimeCommands commands,
RouteMovementStage stage)
{
MovementInput input;
double durationSeconds;
switch (stage)
{
case RouteMovementStage.ForwardOne:
case RouteMovementStage.ForwardTwo:
input = new MovementInput(Forward: true, Run: true);
durationSeconds = 4.0;
break;
case RouteMovementStage.TurnRight:
input = new MovementInput(TurnRight: true);
durationSeconds = 2.0;
break;
default:
throw new ArgumentOutOfRangeException(nameof(stage));
}
Require(
commands.Movement.SetIntent(view.Generation, input),
Stops[_nextStop].Name);
_movementStage = stage;
_movementDeadline =
view.Clock.SimulationTimeSeconds + durationSeconds;
}
public void OnLifecycle(in RuntimeLifecycleDelta delta)
{
}
public void OnCommand(in RuntimeCommandDelta delta)
{
}
public void OnEntity(in RuntimeEntityDelta delta)
{
}
public void OnInventory(in RuntimeInventoryDelta delta)
{
}
public void OnChat(in RuntimeChatDelta delta)
{
}
public void OnMovement(in RuntimeMovementDelta delta)
{
}
public void OnPortal(in RuntimePortalDelta delta)
{
}
public void OnCombat(in RuntimeCombatDelta delta)
{
}
public void Dispose()
{
}
private static bool HasLocalPlayer(IGameRuntimeView view) =>
view.Lifecycle.State is RuntimeLifecycleState.InWorld
&& view.Lifecycle.PlayerGuid != 0u
&& view.Entities.TryGet(
view.Lifecycle.PlayerGuid,
out _);
private static void Require(
in RuntimeCommandResult result,
string stop)
{
if (!result.Accepted)
{
throw new InvalidOperationException(
$"Portal route '{stop}' command was rejected with "
+ $"{result.Status}.");
}
}
private readonly record struct RouteStop(
string Name,
string? ServerCommand,
uint? ExpectedCell);
private enum RouteMovementStage
{
None,
ForwardOne,
TurnRight,
ForwardTwo,
}
}

View file

@ -3,6 +3,7 @@ using AcDream.Core.Net;
using AcDream.Core.Net.Messages; using AcDream.Core.Net.Messages;
using AcDream.Core.Physics; using AcDream.Core.Physics;
using AcDream.Runtime.Entities; using AcDream.Runtime.Entities;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.World; using AcDream.Runtime.World;
namespace AcDream.Runtime.Session; namespace AcDream.Runtime.Session;
@ -15,7 +16,8 @@ public interface IRuntimeDirectWorldProjection
void ProjectPosition( void ProjectPosition(
RuntimeEntityRecord record, RuntimeEntityRecord record,
bool isLocalPlayer); bool isLocalPlayer,
PositionTimestampDisposition disposition);
void BeginTeleport(); void BeginTeleport();
@ -36,6 +38,8 @@ public sealed class RuntimeLiveEntitySessionController
private readonly WorldSession _session; private readonly WorldSession _session;
private readonly Action<string> _log; private readonly Action<string> _log;
private readonly IRuntimeDirectWorldProjection? _worldProjection; private readonly IRuntimeDirectWorldProjection? _worldProjection;
private readonly LocalPlayerOutboundController _localPlayerOutbound =
new((_, _, _, _, _, _) => { });
public RuntimeLiveEntitySessionController( public RuntimeLiveEntitySessionController(
GameRuntime runtime, GameRuntime runtime,
@ -135,11 +139,13 @@ public sealed class RuntimeLiveEntitySessionController
{ {
bool isLocal = bool isLocal =
update.Guid == _runtime.PlayerIdentity.ServerGuid; update.Guid == _runtime.PlayerIdentity.ServerGuid;
PlayerMovementController? localController =
isLocal ? _runtime.MovementOwner.Controller : null;
bool known = Entities.TryApplyPosition( bool known = Entities.TryApplyPosition(
update, update,
isLocal, isLocal,
forcePositionRotation: null, forcePositionRotation: localController?.BodyOrientation,
currentLocalVelocity: null, currentLocalVelocity: localController?.BodyVelocity,
projectionRequiresTeleportHook: false, projectionRequiresTeleportHook: false,
acknowledgeProjection: null, acknowledgeProjection: null,
out PositionTimestampDisposition disposition, out PositionTimestampDisposition disposition,
@ -152,34 +158,44 @@ public sealed class RuntimeLiveEntitySessionController
return; return;
} }
var position = update.Position; if (disposition is PositionTimestampDisposition.Apply)
var destination = new RuntimeTeleportDestination( {
update.Guid, var position = update.Position;
update.InstanceSequence, var destination = new RuntimeTeleportDestination(
update.PositionSequence, update.Guid,
update.TeleportSequence, update.InstanceSequence,
update.ForcePositionSequence, update.PositionSequence,
new Position( update.TeleportSequence,
position.LandblockId, update.ForcePositionSequence,
new Vector3( new Position(
position.PositionX, position.LandblockId,
position.PositionY, new Vector3(
position.PositionZ), position.PositionX,
new Quaternion( position.PositionY,
position.RotationX, position.PositionZ),
position.RotationY, new Quaternion(
position.RotationZ, position.RotationX,
position.RotationW))); position.RotationY,
_runtime.TransitOwner.OfferTeleportDestination( position.RotationZ,
destination, position.RotationW)));
timestamps.TeleportAdvanced); _runtime.TransitOwner.OfferTeleportDestination(
destination,
timestamps.TeleportAdvanced);
}
if (Entities.Entities.TryGetActive( if (Entities.Entities.TryGetActive(
update.Guid, update.Guid,
out RuntimeEntityRecord record)) out RuntimeEntityRecord record))
{ {
_worldProjection?.ProjectPosition( _worldProjection?.ProjectPosition(
record, record,
isLocalPlayer: true); isLocalPlayer: true,
disposition);
}
if (disposition is PositionTimestampDisposition.ForcePosition)
{
_localPlayerOutbound.SendImmediatePosition(
_session,
_runtime.MovementOwner.Controller);
} }
TryCompletePortal(); TryCompletePortal();
} }

View file

@ -31,6 +31,33 @@ public class PhysicsEngineTests
return engine; return engine;
} }
[Fact]
public void Resolve_ZeroDeltaSnapTrace_IsExplicitlyOptIn()
{
var engine = new PhysicsEngine();
var diagnostics = new List<string>();
Assert.Null(engine.DiagnosticLog);
_ = engine.Resolve(
Vector3.Zero,
cellId: 0xA9B40001u,
delta: Vector3.Zero,
stepUpHeight: 0.5f);
engine.DiagnosticLog = diagnostics.Add;
_ = engine.Resolve(
Vector3.Zero,
cellId: 0xA9B40001u,
delta: Vector3.Zero,
stepUpHeight: 0.5f);
Assert.Single(diagnostics);
Assert.StartsWith(
"[snap] claim=0xA9B40001",
diagnostics[0],
StringComparison.Ordinal);
}
[Fact] [Fact]
public void Resolve_FlatTerrain_ZMatchesTerrain() public void Resolve_FlatTerrain_ZMatchesTerrain()
{ {

View file

@ -1,3 +1,5 @@
using AcDream.Headless.Configuration;
namespace AcDream.Headless.Tests; namespace AcDream.Headless.Tests;
public sealed class HeadlessEntryPointTests public sealed class HeadlessEntryPointTests
@ -17,10 +19,48 @@ public sealed class HeadlessEntryPointTests
Assert.Equal(0, exitCode); Assert.Equal(0, exitCode);
Assert.Contains("acdream-headless", output.ToString()); Assert.Contains("acdream-headless", output.ToString());
Assert.Contains("Secrets are never accepted", output.ToString()); Assert.Contains("--password", output.ToString());
Assert.Equal(string.Empty, error.ToString()); Assert.Equal(string.Empty, error.ToString());
} }
[Fact]
public void RunCommandAcceptsDirectCredentialAliases()
{
HeadlessCommandLine command = HeadlessCommandLine.Parse(
[
"run",
"--config",
"bot.json",
"-user",
"testaccount",
"--password",
"fixture-password",
]);
Assert.Equal("testaccount", command.DirectCredentials?.User);
Assert.Equal(
"fixture-password",
command.DirectCredentials?.Password);
}
[Theory]
[InlineData("-user", "testaccount")]
[InlineData("--password", "fixture-password")]
public void DirectCredentialPairMustBeComplete(
string option,
string value)
{
Assert.Throws<HeadlessCommandLineException>(
() => HeadlessCommandLine.Parse(
[
"run",
"--config",
"bot.json",
option,
value,
]));
}
[Fact] [Fact]
public void ValidateAcceptsStrictEmptyNoConnectConfiguration() public void ValidateAcceptsStrictEmptyNoConnectConfiguration()
{ {

View file

@ -95,6 +95,41 @@ public sealed class HeadlessSessionHostTests
diagnostics.ToString()); 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] [Fact]
public void DirectFrameUsesSharedRetailOrderAndMovementCadence() public void DirectFrameUsesSharedRetailOrderAndMovementCadence()
{ {
@ -220,10 +255,17 @@ public sealed class HeadlessSessionHostTests
PlayerMovementController controller = PlayerMovementController controller =
Assert.IsType<PlayerMovementController>( Assert.IsType<PlayerMovementController>(
runtime.MovementOwner.Controller); runtime.MovementOwner.Controller);
projection.ProjectPosition(record, isLocalPlayer: true); controller.SetPosition(
new Vector3(48f, 49f, 50f),
0xA9B40001u);
projection.ProjectPosition(
record,
isLocalPlayer: true,
PositionTimestampDisposition.Apply);
Assert.Same(controller, runtime.MovementOwner.Controller); Assert.Same(controller, runtime.MovementOwner.Controller);
Assert.Equal(record.LocalEntityId, controller.LocalEntityId); Assert.Equal(record.LocalEntityId, controller.LocalEntityId);
Assert.Equal(new Vector3(48f, 49f, 50f), controller.Position);
Assert.Equal( Assert.Equal(
0xA9B40000u, 0xA9B40000u,
controller.CellId & 0xFFFF0000u); controller.CellId & 0xFFFF0000u);
@ -248,10 +290,89 @@ public sealed class HeadlessSessionHostTests
Assert.True(readiness.IsCollisionReady); Assert.True(readiness.IsCollisionReady);
Assert.False(readiness.IsUnhydratable); Assert.False(readiness.IsUnhydratable);
Assert.Equal(PlayerState.InWorld, controller.State); Assert.Equal(PlayerState.InWorld, controller.State);
Assert.Equal(4, collision.CenterCount); Assert.Equal(3, collision.CenterCount);
Assert.Equal(0xA9B40001u, collision.LastCell); 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( private static HeadlessSessionDescriptor Descriptor(
HeadlessCredentialProviderKind provider = HeadlessCredentialProviderKind provider =
HeadlessCredentialProviderKind.Environment, HeadlessCredentialProviderKind.Environment,
@ -389,6 +510,8 @@ public sealed class HeadlessSessionHostTests
public List<WorldSession> Sessions { get; } = []; public List<WorldSession> Sessions { get; } = [];
public int CreatedSessionCount { get; private set; } public int CreatedSessionCount { get; private set; }
public int DisposedSessionCount { 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) => public IPEndPoint ResolveEndpoint(string host, int port) =>
new(IPAddress.Loopback, port); new(IPAddress.Loopback, port);
@ -406,6 +529,8 @@ public sealed class HeadlessSessionHostTests
string user, string user,
string password) string password)
{ {
LastUser = user;
LastPassword = password;
} }
public CharacterList.Parsed GetCharacters( public CharacterList.Parsed GetCharacters(

View file

@ -145,6 +145,9 @@ public sealed class RuntimeLiveEntitySessionControllerTests
Assert.Equal(1, projection.PrepareCount); Assert.Equal(1, projection.PrepareCount);
Assert.True(projection.LastSpawnWasLocal); Assert.True(projection.LastSpawnWasLocal);
Assert.True(projection.LastPositionWasLocal); Assert.True(projection.LastPositionWasLocal);
Assert.Equal(
PositionTimestampDisposition.Apply,
projection.LastPositionDisposition);
Assert.Equal(playerGuid, projection.LastRecord?.ServerGuid); Assert.Equal(playerGuid, projection.LastRecord?.ServerGuid);
Assert.Equal(0x01020001u, projection.LastDestination.CellId); Assert.Equal(0x01020001u, projection.LastDestination.CellId);
Assert.True(runtime.TransitOwner.CaptureOwnership().IsSessionIdle); Assert.True(runtime.TransitOwner.CaptureOwnership().IsSessionIdle);
@ -269,6 +272,11 @@ public sealed class RuntimeLiveEntitySessionControllerTests
public int PrepareCount { get; private set; } public int PrepareCount { get; private set; }
public bool LastSpawnWasLocal { get; private set; } public bool LastSpawnWasLocal { get; private set; }
public bool LastPositionWasLocal { get; private set; } public bool LastPositionWasLocal { get; private set; }
public PositionTimestampDisposition LastPositionDisposition
{
get;
private set;
}
public RuntimeEntityRecord? LastRecord { get; private set; } public RuntimeEntityRecord? LastRecord { get; private set; }
public RuntimeTeleportDestination LastDestination { get; private set; } public RuntimeTeleportDestination LastDestination { get; private set; }
@ -283,11 +291,13 @@ public sealed class RuntimeLiveEntitySessionControllerTests
public void ProjectPosition( public void ProjectPosition(
RuntimeEntityRecord record, RuntimeEntityRecord record,
bool isLocalPlayer) bool isLocalPlayer,
PositionTimestampDisposition disposition)
{ {
PositionCount++; PositionCount++;
LastRecord = record; LastRecord = record;
LastPositionWasLocal = isLocalPlayer; LastPositionWasLocal = isLocalPlayer;
LastPositionDisposition = disposition;
} }
public void BeginTeleport() => TeleportStartCount++; public void BeginTeleport() => TeleportStartCount++;