acdream/src/AcDream.Headless/Policies/HeadlessBotPolicy.cs
Erik c1f1582576 fix(chat): Campaign CH user-gate round 2 -- portal notice rerouted to SpewBox, verbatim /help extraction, jump-in-air evidence
Item 2: retail's portal-space "In Portal Space..." notice is the SpewBox
(ECM_UI::SendNotice_DisplayStringInfo(0x1A,...) -> AddTextToScroll(str,
0x1A, 1, 0), hardcoded to the SpewBox per the decomp), not a dedicated
centered overlay. PortalWaitNoticeController and its lease are deleted;
PortalTunnelPresentation's per-rotation-segment cadence now writes
straight into RuntimeCommunicationState.AddText(ClientLocal) -- the
SpewBox's own dedupe-at-index-0 handles the repetition exactly as
retail's does. Register row AP-184 records the surface fix and the AP-178
scope extension.

Items 4+5: /help text was partially fabricated -- the user caught the
"/help death" meta-message. Generalized
tools/pdb-extract/sweep_weenie_strings.py to decode narrow
PStringBase<char> literals (the ClientCommunicationSystem::Help* family's
shape) alongside its original UTF-16LE support, then swept every
HelpXxxGroup function's exact byte extent against the PDB-paired
acclient.exe. 4 of 7 group topics (death/status/text/allegiances) are now
complete verbatim listings; the other 3 (channels/chatting/commands) keep
an honest UNVERIFIED note citing HelpStupidChannelHack @0x0056f290 (a
genuinely undecodable BN-mislabeled-fragment mechanism) instead of the
old fabricated sentinel. 7 of ~35 channel one-liners are also now
verbatim. ISSUES.md #364 tracks the remainder;
RetailCommandHelpTableTests.cs pins every result byte-exact.

Item 1: jump-in-air refusal still silent live is NOT reproduced and NOT
speculatively fixed. Exhaustive static re-audit found the mechanism
correct by construction (single-writer OnWalkable, exactly-once-per-frame
Update()/Capture(), no interfering edge-history resets). A live headless
repro (new jump-probe bot policy, real ACE connect) was blocked --
probeaccount2 has no character, and the graphical client already owned
testaccount this session so the task's own fallback rule forbade using
it. Two temporary probes are left behind ACDREAM_PROBE_JUMP=1 (blocked
entirely in Headless by the existing multi-session static-state guard --
graphical-only for the next round).

Item 3 confirmed fixed, no regression. Item 6 (resize: no diagonal
cursors, cannot grow Y from bottom-right) folded into CH6a's existing
scope.

Full Release suite: 12,267 passed / 4 skipped / 0 failed (up from
12,221/4/0).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 08:40:24 +02:00

740 lines
22 KiB
C#

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()
{
}
}
/// <summary>
/// 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.
/// </summary>
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}.");
}
}
}
/// <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,
}
}
/// <summary>
/// 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
/// <c>PlayerMovementController.Update</c>
/// (<c>input.Jump &amp;&amp; !_prevJumpHeld &amp;&amp; !_body.OnWalkable</c>
/// → <c>ReportJumpRefusal(NotGrounded)</c>) with a passing unit test, but the
/// user reported it STILL silent live. This policy drives the SAME typed
/// <c>commands.Movement.SetIntent</c> surface a headless bot uses (the
/// <c>RuntimeLocalPlayerMovementState.HasCommandInput</c> route — distinct
/// from the graphical dispatcher's held-key polling path) to charge and fire
/// a jump, wait for <see cref="RuntimeMovementSnapshot.IsAirborne"/> to go
/// true under REAL physics against a live ACE connection, then press jump
/// again mid-air. Pair with <c>ACDREAM_PROBE_JUMP=1</c> (prints
/// <c>[jump]</c>/<c>[jump-tick]</c> lines from <c>PlayerMovementController</c>
/// itself) to see whether OnWalkable actually clears while genuinely
/// airborne and whether <c>ReportJumpRefusal</c> is reached.
/// </summary>
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}.");
}
}
}