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)
{
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}")); $"[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)
return;
if (_runtime.MovementOwner.Controller is null)
{ {
if (isLocalPlayer)
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,6 +158,8 @@ public sealed class RuntimeLiveEntitySessionController
return; return;
} }
if (disposition is PositionTimestampDisposition.Apply)
{
var position = update.Position; var position = update.Position;
var destination = new RuntimeTeleportDestination( var destination = new RuntimeTeleportDestination(
update.Guid, update.Guid,
@ -173,13 +181,21 @@ public sealed class RuntimeLiveEntitySessionController
_runtime.TransitOwner.OfferTeleportDestination( _runtime.TransitOwner.OfferTeleportDestination(
destination, destination,
timestamps.TeleportAdvanced); 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++;