using AcDream.Headless.Configuration; using AcDream.Runtime; 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(), _ => 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}."); } } }