From 3f3401257c83bdd9f557eb577ba08cf2e5ec9a06 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 27 Jul 2026 10:26:44 +0200 Subject: [PATCH] fix(headless): complete connected movement gate --- src/AcDream.Core/Physics/PhysicsEngine.cs | 29 +- .../Configuration/HeadlessCommandLine.cs | 32 +- src/AcDream.Headless/HeadlessEntryPoint.cs | 10 +- .../Hosting/HeadlessProcessHost.cs | 37 +- .../Hosting/HeadlessSessionWorldProjection.cs | 36 +- .../Policies/HeadlessBotPolicy.cs | 384 ++++++++++++++++++ .../RuntimeLiveEntitySessionController.cs | 66 +-- .../Physics/PhysicsEngineTests.cs | 27 ++ .../HeadlessEntryPointTests.cs | 42 +- .../HeadlessSessionHostTests.cs | 129 +++++- ...RuntimeLiveEntitySessionControllerTests.cs | 12 +- 11 files changed, 756 insertions(+), 48 deletions(-) diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index 56348666..5b4dbf6e 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -55,6 +55,12 @@ public sealed class PhysicsEngine /// Number of registered landblocks (diagnostic). public int LandblockCount => _landblocks.Count; + /// + /// Optional high-volume collision trace sink. Production leaves this + /// unset; focused diagnostic gates may opt in explicitly. + /// + public Action? DiagnosticLog { get; set; } + /// /// True once the landblock covering has had its /// terrain + cells registered via . Accepts a canonical @@ -797,8 +803,8 @@ public sealed class PhysicsEngine if (physics is null) { - if (snapDiag) - Console.WriteLine(System.FormattableString.Invariant( + if (snapDiag && DiagnosticLog is { } noLandblockLog) + 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")); return new ResolveResult(candidatePos, cellId, IsOnGround: false); } @@ -831,8 +837,11 @@ public sealed class PhysicsEngine float? claimFloorZ = WalkableFloorZNearest(cellId, candidatePos, currentPos.Z); if (claimFloorZ is not null) { - Console.WriteLine(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}")); + 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}")); + } // #133 (2026-06-13): return the VALIDATED claim's OWN full cell id, // NOT lbPrefix | (cellId & 0xFFFF). lbPrefix is found by scanning // 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 — // our recoverable stand-in for retail's lost-cell machinery // (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)")); + } targetZ = currentPos.Z; } } @@ -1028,8 +1041,8 @@ public sealed class PhysicsEngine // Step-height enforcement: block upward movement that exceeds the limit. float zDelta = targetZ - currentPos.Z; - if (snapDiag) - Console.WriteLine(System.FormattableString.Invariant( + if (snapDiag && DiagnosticLog is { } resultLog) + 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}")); if (zDelta > stepUpHeight) { diff --git a/src/AcDream.Headless/Configuration/HeadlessCommandLine.cs b/src/AcDream.Headless/Configuration/HeadlessCommandLine.cs index ad60c815..38009512 100644 --- a/src/AcDream.Headless/Configuration/HeadlessCommandLine.cs +++ b/src/AcDream.Headless/Configuration/HeadlessCommandLine.cs @@ -3,7 +3,8 @@ namespace AcDream.Headless.Configuration; internal sealed record HeadlessCommandLine( string Command, string ConfigurationPath, - HeadlessPathOverrides Paths) + HeadlessPathOverrides Paths, + HeadlessDirectCredentials? DirectCredentials) { internal static HeadlessCommandLine Parse( IReadOnlyList arguments) @@ -20,6 +21,8 @@ internal sealed record HeadlessCommandLine( string? configDirectory = null; string? dataDirectory = null; string? cacheDirectory = null; + string? user = null; + string? password = null; for (int index = 1; index < arguments.Count; index += 2) { if (index + 1 >= arguments.Count) @@ -50,6 +53,14 @@ internal sealed record HeadlessCommandLine( case "--cache-dir": SetOnce(ref cacheDirectory, value); break; + case "-user": + case "--user": + SetOnce(ref user, value); + break; + case "-password": + case "--password": + SetOnce(ref password, value); + break; default: throw new HeadlessCommandLineException( "Unknown command option."); @@ -61,6 +72,16 @@ internal sealed record HeadlessCommandLine( throw new HeadlessCommandLineException( "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( command, @@ -68,7 +89,10 @@ internal sealed record HeadlessCommandLine( new HeadlessPathOverrides( configDirectory, dataDirectory, - cacheDirectory)); + cacheDirectory), + user is null + ? null + : new HeadlessDirectCredentials(user, password!)); } private static void SetOnce(ref string? destination, string value) @@ -81,3 +105,7 @@ internal sealed record HeadlessCommandLine( destination = value; } } + +internal sealed record HeadlessDirectCredentials( + string User, + string Password); diff --git a/src/AcDream.Headless/HeadlessEntryPoint.cs b/src/AcDream.Headless/HeadlessEntryPoint.cs index 590b09aa..e5576e54 100644 --- a/src/AcDream.Headless/HeadlessEntryPoint.cs +++ b/src/AcDream.Headless/HeadlessEntryPoint.cs @@ -14,7 +14,7 @@ internal static class HeadlessEntryPoint Usage: acdream-headless --help acdream-headless validate --config [path overrides] - acdream-headless run --config [path overrides] + acdream-headless run --config [path overrides] [credentials] Commands: validate Validate a versioned headless configuration without connecting. @@ -25,7 +25,9 @@ internal static class HeadlessEntryPoint --data-dir --cache-dir - Secrets are never accepted on the command line. + Direct single-session credentials: + -user -password + --user --password """; internal static int Run( @@ -76,7 +78,9 @@ internal static class HeadlessEntryPoint configuration, paths, standardInput, - output); + output, + directCredentials: + commandLine.DirectCredentials); return (int)host.RunAsync(cancellationToken) .GetAwaiter() .GetResult(); diff --git a/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs b/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs index 7892b97a..db943b7f 100644 --- a/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs @@ -23,7 +23,8 @@ internal sealed class HeadlessProcessHost : IDisposable TextWriter diagnostics, ILiveSessionOperations? sessionOperations = null, TimeProvider? timeProvider = null, - IHeadlessProcessContentFactory? contentFactory = null) + IHeadlessProcessContentFactory? contentFactory = null, + HeadlessDirectCredentials? directCredentials = null) { ArgumentNullException.ThrowIfNull(configuration); ArgumentNullException.ThrowIfNull(paths); @@ -34,6 +35,12 @@ internal sealed class HeadlessProcessHost : IDisposable throw new HeadlessConfigurationException( "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(); _diagnostics = new HeadlessDiagnosticWriter(diagnostics); @@ -61,10 +68,19 @@ internal sealed class HeadlessProcessHost : IDisposable HeadlessSessionDescriptor descriptor = candidate ?? throw new HeadlessConfigurationException( "Headless run mode cannot contain a null session."); - HeadlessCredentialSecret secret = - credentials.Resolve( + if (directCredentials is not null) + { + descriptor = WithAccount( + descriptor, + directCredentials.User); + } + HeadlessCredentialSecret secret = directCredentials is null + ? credentials.Resolve( descriptor.Id, - descriptor.Credential); + descriptor.Credential) + : new HeadlessCredentialSecret( + "command-line", + directCredentials.Password); HeadlessProcessContentOwner.HeadlessProcessContentLease? contentLease = content?.AcquireLease(descriptor.Id); try @@ -111,6 +127,19 @@ internal sealed class HeadlessProcessHost : IDisposable internal HeadlessProcessContentSnapshot? Content => _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 RunAsync( CancellationToken cancellationToken) { diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs b/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs index 4f4f8f3c..373a4d3d 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs @@ -293,10 +293,20 @@ internal sealed class HeadlessSessionWorldProjection public void ProjectPosition( RuntimeEntityRecord record, - bool isLocalPlayer) + bool isLocalPlayer, + PositionTimestampDisposition disposition) { - if (isLocalPlayer) + if (!isLocalPlayer) + return; + + if (_runtime.MovementOwner.Controller is null) + { SynchronizeLocalPlayer(record); + return; + } + + if (disposition is PositionTimestampDisposition.ForcePosition) + BlipLocalPlayer(record); } public void BeginTeleport() @@ -383,6 +393,28 @@ internal sealed class HeadlessSessionWorldProjection 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( RuntimeEntityRecord record) { diff --git a/src/AcDream.Headless/Policies/HeadlessBotPolicy.cs b/src/AcDream.Headless/Policies/HeadlessBotPolicy.cs index 2ac23bf8..a38ae1ad 100644 --- a/src/AcDream.Headless/Policies/HeadlessBotPolicy.cs +++ b/src/AcDream.Headless/Policies/HeadlessBotPolicy.cs @@ -1,5 +1,6 @@ using AcDream.Headless.Configuration; using AcDream.Runtime; +using AcDream.Runtime.Gameplay; namespace AcDream.Headless.Policies; @@ -17,6 +18,8 @@ internal static class HeadlessBotPolicyFactory { "idle" => new IdleHeadlessBotPolicy(), "lifecycle-smoke" => new LifecycleSmokeHeadlessBotPolicy(), + "observer-movement" => new ObserverMovementHeadlessBotPolicy(), + "portal-route-smoke" => new PortalRouteSmokeHeadlessBotPolicy(), _ => throw new HeadlessConfigurationException( $"Unknown headless bot policy '{id}'."), }; @@ -189,3 +192,384 @@ internal sealed class LifecycleSmokeHeadlessBotPolicy } } } + +/// +/// 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. +/// +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}."); + } + } +} + +/// +/// 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. +/// +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, + } +} diff --git a/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs b/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs index 8d0559b5..4fada9f0 100644 --- a/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs +++ b/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs @@ -3,6 +3,7 @@ using AcDream.Core.Net; using AcDream.Core.Net.Messages; using AcDream.Core.Physics; using AcDream.Runtime.Entities; +using AcDream.Runtime.Gameplay; using AcDream.Runtime.World; namespace AcDream.Runtime.Session; @@ -15,7 +16,8 @@ public interface IRuntimeDirectWorldProjection void ProjectPosition( RuntimeEntityRecord record, - bool isLocalPlayer); + bool isLocalPlayer, + PositionTimestampDisposition disposition); void BeginTeleport(); @@ -36,6 +38,8 @@ public sealed class RuntimeLiveEntitySessionController private readonly WorldSession _session; private readonly Action _log; private readonly IRuntimeDirectWorldProjection? _worldProjection; + private readonly LocalPlayerOutboundController _localPlayerOutbound = + new((_, _, _, _, _, _) => { }); public RuntimeLiveEntitySessionController( GameRuntime runtime, @@ -135,11 +139,13 @@ public sealed class RuntimeLiveEntitySessionController { bool isLocal = update.Guid == _runtime.PlayerIdentity.ServerGuid; + PlayerMovementController? localController = + isLocal ? _runtime.MovementOwner.Controller : null; bool known = Entities.TryApplyPosition( update, isLocal, - forcePositionRotation: null, - currentLocalVelocity: null, + forcePositionRotation: localController?.BodyOrientation, + currentLocalVelocity: localController?.BodyVelocity, projectionRequiresTeleportHook: false, acknowledgeProjection: null, out PositionTimestampDisposition disposition, @@ -152,34 +158,44 @@ public sealed class RuntimeLiveEntitySessionController return; } - var position = update.Position; - var destination = new RuntimeTeleportDestination( - update.Guid, - update.InstanceSequence, - update.PositionSequence, - update.TeleportSequence, - update.ForcePositionSequence, - new Position( - position.LandblockId, - new Vector3( - position.PositionX, - position.PositionY, - position.PositionZ), - new Quaternion( - position.RotationX, - position.RotationY, - position.RotationZ, - position.RotationW))); - _runtime.TransitOwner.OfferTeleportDestination( - destination, - timestamps.TeleportAdvanced); + if (disposition is PositionTimestampDisposition.Apply) + { + var position = update.Position; + var destination = new RuntimeTeleportDestination( + update.Guid, + update.InstanceSequence, + update.PositionSequence, + update.TeleportSequence, + update.ForcePositionSequence, + new Position( + position.LandblockId, + new Vector3( + position.PositionX, + position.PositionY, + position.PositionZ), + new Quaternion( + position.RotationX, + position.RotationY, + position.RotationZ, + position.RotationW))); + _runtime.TransitOwner.OfferTeleportDestination( + destination, + timestamps.TeleportAdvanced); + } if (Entities.Entities.TryGetActive( update.Guid, out RuntimeEntityRecord record)) { _worldProjection?.ProjectPosition( record, - isLocalPlayer: true); + isLocalPlayer: true, + disposition); + } + if (disposition is PositionTimestampDisposition.ForcePosition) + { + _localPlayerOutbound.SendImmediatePosition( + _session, + _runtime.MovementOwner.Controller); } TryCompletePortal(); } diff --git a/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs b/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs index b61d4329..473650b3 100644 --- a/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs +++ b/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs @@ -31,6 +31,33 @@ public class PhysicsEngineTests return engine; } + [Fact] + public void Resolve_ZeroDeltaSnapTrace_IsExplicitlyOptIn() + { + var engine = new PhysicsEngine(); + var diagnostics = new List(); + + 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] public void Resolve_FlatTerrain_ZMatchesTerrain() { diff --git a/tests/AcDream.Headless.Tests/HeadlessEntryPointTests.cs b/tests/AcDream.Headless.Tests/HeadlessEntryPointTests.cs index 7667e25c..02ffca6e 100644 --- a/tests/AcDream.Headless.Tests/HeadlessEntryPointTests.cs +++ b/tests/AcDream.Headless.Tests/HeadlessEntryPointTests.cs @@ -1,3 +1,5 @@ +using AcDream.Headless.Configuration; + namespace AcDream.Headless.Tests; public sealed class HeadlessEntryPointTests @@ -17,10 +19,48 @@ public sealed class HeadlessEntryPointTests Assert.Equal(0, exitCode); 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()); } + [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( + () => HeadlessCommandLine.Parse( + [ + "run", + "--config", + "bot.json", + option, + value, + ])); + } + [Fact] public void ValidateAcceptsStrictEmptyNoConnectConfiguration() { diff --git a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs index e64812be..32823794 100644 --- a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs +++ b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs @@ -95,6 +95,41 @@ public sealed class HeadlessSessionHostTests 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() { @@ -220,10 +255,17 @@ public sealed class HeadlessSessionHostTests PlayerMovementController controller = Assert.IsType( 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.Equal(record.LocalEntityId, controller.LocalEntityId); + Assert.Equal(new Vector3(48f, 49f, 50f), controller.Position); Assert.Equal( 0xA9B40000u, controller.CellId & 0xFFFF0000u); @@ -248,10 +290,89 @@ public sealed class HeadlessSessionHostTests Assert.True(readiness.IsCollisionReady); Assert.False(readiness.IsUnhydratable); Assert.Equal(PlayerState.InWorld, controller.State); - Assert.Equal(4, collision.CenterCount); + 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( + 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, @@ -389,6 +510,8 @@ public sealed class HeadlessSessionHostTests public List 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); @@ -406,6 +529,8 @@ public sealed class HeadlessSessionHostTests string user, string password) { + LastUser = user; + LastPassword = password; } public CharacterList.Parsed GetCharacters( diff --git a/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs b/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs index 12286bca..524bdb92 100644 --- a/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs @@ -145,6 +145,9 @@ public sealed class RuntimeLiveEntitySessionControllerTests Assert.Equal(1, projection.PrepareCount); Assert.True(projection.LastSpawnWasLocal); Assert.True(projection.LastPositionWasLocal); + Assert.Equal( + PositionTimestampDisposition.Apply, + projection.LastPositionDisposition); Assert.Equal(playerGuid, projection.LastRecord?.ServerGuid); Assert.Equal(0x01020001u, projection.LastDestination.CellId); Assert.True(runtime.TransitOwner.CaptureOwnership().IsSessionIdle); @@ -269,6 +272,11 @@ public sealed class RuntimeLiveEntitySessionControllerTests public int PrepareCount { get; private set; } public bool LastSpawnWasLocal { get; private set; } public bool LastPositionWasLocal { get; private set; } + public PositionTimestampDisposition LastPositionDisposition + { + get; + private set; + } public RuntimeEntityRecord? LastRecord { get; private set; } public RuntimeTeleportDestination LastDestination { get; private set; } @@ -283,11 +291,13 @@ public sealed class RuntimeLiveEntitySessionControllerTests public void ProjectPosition( RuntimeEntityRecord record, - bool isLocalPlayer) + bool isLocalPlayer, + PositionTimestampDisposition disposition) { PositionCount++; LastRecord = record; LastPositionWasLocal = isLocalPlayer; + LastPositionDisposition = disposition; } public void BeginTeleport() => TeleportStartCount++;