fix(headless): complete connected movement gate
This commit is contained in:
parent
fd9559a063
commit
3f3401257c
11 changed files with 756 additions and 48 deletions
|
|
@ -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<string> 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);
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ internal static class HeadlessEntryPoint
|
|||
Usage:
|
||||
acdream-headless --help
|
||||
acdream-headless validate --config <path> [path overrides]
|
||||
acdream-headless run --config <path> [path overrides]
|
||||
acdream-headless run --config <path> [path overrides] [credentials]
|
||||
|
||||
Commands:
|
||||
validate Validate a versioned headless configuration without connecting.
|
||||
|
|
@ -25,7 +25,9 @@ internal static class HeadlessEntryPoint
|
|||
--data-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(
|
||||
|
|
@ -76,7 +78,9 @@ internal static class HeadlessEntryPoint
|
|||
configuration,
|
||||
paths,
|
||||
standardInput,
|
||||
output);
|
||||
output,
|
||||
directCredentials:
|
||||
commandLine.DirectCredentials);
|
||||
return (int)host.RunAsync(cancellationToken)
|
||||
.GetAwaiter()
|
||||
.GetResult();
|
||||
|
|
|
|||
|
|
@ -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<HeadlessExitCode> RunAsync(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue