using AcDream.Headless.Configuration; using AcDream.Runtime; using AcDream.Runtime.Gameplay; namespace AcDream.Headless.Policies; internal interface IHeadlessBotPolicy : IRuntimeEventObserver, IDisposable { bool IsComplete { get; } void Tick(IGameRuntimeView view, IGameRuntimeCommands commands); } internal static class HeadlessBotPolicyFactory { internal static IHeadlessBotPolicy Create(string id) => id switch { "idle" => new IdleHeadlessBotPolicy(), "lifecycle-smoke" => new LifecycleSmokeHeadlessBotPolicy(), "observer-movement" => new ObserverMovementHeadlessBotPolicy(), "portal-route-smoke" => new PortalRouteSmokeHeadlessBotPolicy(), "jump-probe" => new JumpProbeHeadlessBotPolicy(), _ => throw new HeadlessConfigurationException( $"Unknown headless bot policy '{id}'."), }; } internal sealed class IdleHeadlessBotPolicy : IHeadlessBotPolicy { public bool IsComplete => false; public void Tick( IGameRuntimeView view, IGameRuntimeCommands commands) { ArgumentNullException.ThrowIfNull(view); ArgumentNullException.ThrowIfNull(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() { } } /// /// Explicit connected-gate policy: wait for the local player, issue one /// harmless local-speech command and one lifestone recall, reconnect after /// the exact portal completes, then terminate after the new generation has /// materialized the player. It is never selected implicitly. /// internal sealed class LifecycleSmokeHeadlessBotPolicy : IHeadlessBotPolicy { private int _stage; private ulong _firstGeneration; public bool IsComplete => _stage == 3; public void Tick( IGameRuntimeView view, IGameRuntimeCommands commands) { ArgumentNullException.ThrowIfNull(view); ArgumentNullException.ThrowIfNull(commands); switch (_stage) { case 0: if (!HasLocalPlayer(view)) return; Require(commands.Chat.Execute( view.Generation, new RuntimeChatCommand( RuntimeChatChannel.Say, "[acdream headless lifecycle gate]"))); Require(commands.Portal.Execute( view.Generation, RuntimePortalCommand.RecallLifestone)); _firstGeneration = view.Generation.Value; _stage = 1; break; case 1: if (view.Portal.Snapshot.Kind is not RuntimePortalKind.Portal || !view.Portal.Snapshot.Completed) { return; } RuntimeSessionStartResult reconnect = commands.Session.Reconnect(view.Generation); if (reconnect.Status is not ( RuntimeSessionStartStatus.Connected or RuntimeSessionStartStatus.Deferred)) { throw new InvalidOperationException( $"Lifecycle gate reconnect was rejected with {reconnect.Status}."); } _stage = 2; break; case 2: if (view.Generation.Value <= _firstGeneration || !HasLocalPlayer(view)) { return; } _stage = 3; break; } } 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) { if (!result.Accepted) { throw new InvalidOperationException( $"Lifecycle gate command was rejected with {result.Status}."); } } } /// /// 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, } } /// /// Campaign CH user-gate round 2, item 1 (TEMPORARY, evidence-gathering /// only — not a shipped bot behavior). Round 1 added a press-edge branch in /// PlayerMovementController.Update /// (input.Jump && !_prevJumpHeld && !_body.OnWalkable /// → ReportJumpRefusal(NotGrounded)) with a passing unit test, but the /// user reported it STILL silent live. This policy drives the SAME typed /// commands.Movement.SetIntent surface a headless bot uses (the /// RuntimeLocalPlayerMovementState.HasCommandInput route — distinct /// from the graphical dispatcher's held-key polling path) to charge and fire /// a jump, wait for to go /// true under REAL physics against a live ACE connection, then press jump /// again mid-air. Pair with ACDREAM_PROBE_JUMP=1 (prints /// [jump]/[jump-tick] lines from PlayerMovementController /// itself) to see whether OnWalkable actually clears while genuinely /// airborne and whether ReportJumpRefusal is reached. /// internal sealed class JumpProbeHeadlessBotPolicy : IHeadlessBotPolicy { private enum Stage { WaitForPlayer, Charging, WaitReleaseAirborne, WaitMidAirPress, Done, } private Stage _stage = Stage.WaitForPlayer; private double _stageDeadline; private bool _wasAirborne; public bool IsComplete => _stage == Stage.Done; public void Tick(IGameRuntimeView view, IGameRuntimeCommands commands) { ArgumentNullException.ThrowIfNull(view); ArgumentNullException.ThrowIfNull(commands); RuntimeMovementSnapshot movement = view.Movement.Snapshot; if (movement.IsAirborne != _wasAirborne) { Console.WriteLine( $"[jump-probe] airborne-transition {_wasAirborne} -> " + $"{movement.IsAirborne} stage={_stage} " + $"simTime={view.Clock.SimulationTimeSeconds:F3} " + $"hasCommandInput={movement.HasCommandInput} " + $"commandInput.Jump={movement.CommandInput.Jump}"); _wasAirborne = movement.IsAirborne; } switch (_stage) { case Stage.WaitForPlayer: if (!HasLocalPlayer(view)) return; Console.WriteLine("[jump-probe] local player present; charging jump"); Require(commands.Movement.SetIntent( view.Generation, new MovementInput(Jump: true))); _stageDeadline = view.Clock.SimulationTimeSeconds + 0.6; _stage = Stage.Charging; break; case Stage.Charging: if (view.Clock.SimulationTimeSeconds < _stageDeadline) return; Console.WriteLine("[jump-probe] releasing jump (fire)"); Require(commands.Movement.SetIntent( view.Generation, new MovementInput(Jump: false))); _stageDeadline = view.Clock.SimulationTimeSeconds + 3.0; _stage = Stage.WaitReleaseAirborne; break; case Stage.WaitReleaseAirborne: if (movement.IsAirborne) { Console.WriteLine( "[jump-probe] airborne confirmed; pressing jump mid-air " + $"simTime={view.Clock.SimulationTimeSeconds:F3}"); Require(commands.Movement.SetIntent( view.Generation, new MovementInput(Jump: true))); _stageDeadline = view.Clock.SimulationTimeSeconds + 1.0; _stage = Stage.WaitMidAirPress; return; } if (view.Clock.SimulationTimeSeconds >= _stageDeadline) { Console.WriteLine( "[jump-probe] TIMEOUT waiting for airborne after jump " + "fire -- the released jump never registered as " + "airborne."); Require(commands.Movement.ClearIntent(view.Generation)); _stage = Stage.Done; } return; case Stage.WaitMidAirPress: if (view.Clock.SimulationTimeSeconds < _stageDeadline) return; Console.WriteLine( "[jump-probe] probe complete; final airborne=" + $"{movement.IsAirborne}"); Require(commands.Movement.ClearIntent(view.Generation)); _stage = Stage.Done; return; } } 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) { if (!result.Accepted) { throw new InvalidOperationException( $"Jump probe command was rejected with {result.Status}."); } } }