fix(headless): complete connected movement gate
This commit is contained in:
parent
fd9559a063
commit
3f3401257c
11 changed files with 756 additions and 48 deletions
|
|
@ -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)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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++;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue