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>
740 lines
22 KiB
C#
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 && !_prevJumpHeld && !_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}.");
|
|
}
|
|
}
|
|
}
|