test(runtime): add unattended connected R6 soak
Drive turn, movement, jump, and combat through the production InputDispatcher so connected Release testing works without an interactive Windows desktop. Track held automation actions through normal completion and every shutdown path. Add a seven-destination ACE route with post-liveness memory, allocation, update, fatal-log, outbound-movement, and graceful-close gates. Record dynamic ACE population changes as context instead of a false lifetime oracle, and document the accepted rebaseline. Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
parent
f961d70023
commit
a755b764bf
14 changed files with 1021 additions and 20 deletions
|
|
@ -2450,7 +2450,10 @@ public sealed class GameWindow : IDisposable
|
|||
_options.UiProbeEnabled,
|
||||
_options.UiProbeScript,
|
||||
_options.UiProbeDump,
|
||||
UiProbeLog)));
|
||||
UiProbeLog,
|
||||
action => _inputDispatcher?.TryInvokeAutomationAction(action) == true,
|
||||
(action, held) =>
|
||||
_inputDispatcher?.TrySetAutomationActionHeld(action, held) == true)));
|
||||
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ using AcDream.UI.Abstractions;
|
|||
using AcDream.UI.Abstractions.Panels.Chat;
|
||||
using AcDream.UI.Abstractions.Panels.Settings;
|
||||
using AcDream.UI.Abstractions.Panels.Vitals;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
using DatReaderWriter;
|
||||
using Silk.NET.Input;
|
||||
|
||||
|
|
@ -146,7 +147,9 @@ public sealed record RetailUiProbeBindings(
|
|||
bool Enabled,
|
||||
string? ScriptPath,
|
||||
bool DumpOnStart,
|
||||
Action<string> Log);
|
||||
Action<string> Log,
|
||||
Func<InputAction, bool> PressInput,
|
||||
Func<InputAction, bool, bool> SetInputHeld);
|
||||
|
||||
public sealed record RetailUiCursorBindings(
|
||||
CursorFeedbackController Feedback,
|
||||
|
|
@ -261,7 +264,9 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
text,
|
||||
bindings.Chat.ViewModel,
|
||||
bindings.Chat.CommandBus(),
|
||||
ChatChannelKind.Say));
|
||||
ChatChannelKind.Say),
|
||||
bindings.Probe.PressInput,
|
||||
bindings.Probe.SetInputHeld);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1723,6 +1728,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
return;
|
||||
|
||||
_shutdown ??= CreateShutdownTransaction(
|
||||
() => _automation?.Dispose(),
|
||||
() => _persistence?.Dispose(),
|
||||
() => Host.WindowManager.WindowVisibilityChanged -= OnWindowVisibilityChanged,
|
||||
() => _itemConfirmationController?.Dispose(),
|
||||
|
|
@ -1735,6 +1741,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
}
|
||||
|
||||
internal static ResourceShutdownTransaction CreateShutdownTransaction(
|
||||
Action disposeAutomation,
|
||||
Action disposePersistence,
|
||||
Action unsubscribeWindowVisibility,
|
||||
Action disposeItemConfirmation,
|
||||
|
|
@ -1743,6 +1750,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
Action disposePanelController,
|
||||
Action disposeHost)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(disposeAutomation);
|
||||
ArgumentNullException.ThrowIfNull(disposePersistence);
|
||||
ArgumentNullException.ThrowIfNull(unsubscribeWindowVisibility);
|
||||
ArgumentNullException.ThrowIfNull(disposeItemConfirmation);
|
||||
|
|
@ -1754,6 +1762,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
return new ResourceShutdownTransaction(
|
||||
new ResourceShutdownStage("retail UI observers",
|
||||
[
|
||||
new("UI automation", disposeAutomation),
|
||||
new("window persistence", disposePersistence),
|
||||
new("window visibility", unsubscribeWindowVisibility),
|
||||
]),
|
||||
|
|
|
|||
|
|
@ -3,21 +3,25 @@ using System.Collections.Generic;
|
|||
using System.Globalization;
|
||||
using System.IO;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
|
||||
namespace AcDream.App.UI.Testing;
|
||||
|
||||
/// <summary>
|
||||
/// Tiny one-thread script runner for <see cref="RetailUiAutomationProbe"/>.
|
||||
/// It is intended for local diagnostic launches, not for gameplay. Commands
|
||||
/// execute on render ticks and use the same <see cref="UiRoot"/> input path as
|
||||
/// physical mouse input.
|
||||
/// execute on render ticks. Pointer commands use the same <see cref="UiRoot"/>
|
||||
/// path as physical mouse input; semantic input uses the production dispatcher.
|
||||
/// </summary>
|
||||
public sealed class RetailUiAutomationScriptRunner
|
||||
public sealed class RetailUiAutomationScriptRunner : IDisposable
|
||||
{
|
||||
private readonly RetailUiAutomationProbe _probe;
|
||||
private readonly Action<string> _log;
|
||||
private readonly Action<string>? _submitCommand;
|
||||
private readonly Func<InputAction, bool>? _pressInput;
|
||||
private readonly Func<InputAction, bool, bool>? _setInputHeld;
|
||||
private readonly List<ScriptCommand> _commands = new();
|
||||
private readonly HashSet<InputAction> _heldInputs = new();
|
||||
private readonly bool _dumpOnStart;
|
||||
private readonly string? _loadError;
|
||||
private int _index;
|
||||
|
|
@ -26,17 +30,22 @@ public sealed class RetailUiAutomationScriptRunner
|
|||
private double _commandElapsedCompensationMs;
|
||||
private bool _started;
|
||||
private bool _completed;
|
||||
private bool _disposed;
|
||||
|
||||
public RetailUiAutomationScriptRunner(
|
||||
RetailUiAutomationProbe probe,
|
||||
string? scriptPath,
|
||||
bool dumpOnStart,
|
||||
Action<string>? log = null,
|
||||
Action<string>? submitCommand = null)
|
||||
Action<string>? submitCommand = null,
|
||||
Func<InputAction, bool>? pressInput = null,
|
||||
Func<InputAction, bool, bool>? setInputHeld = null)
|
||||
{
|
||||
_probe = probe ?? throw new ArgumentNullException(nameof(probe));
|
||||
_log = log ?? (_ => { });
|
||||
_submitCommand = submitCommand;
|
||||
_pressInput = pressInput;
|
||||
_setInputHeld = setInputHeld;
|
||||
_dumpOnStart = dumpOnStart;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(scriptPath))
|
||||
|
|
@ -63,7 +72,7 @@ public sealed class RetailUiAutomationScriptRunner
|
|||
|
||||
public void Tick(double deltaSeconds)
|
||||
{
|
||||
if (_completed) return;
|
||||
if (_completed || _disposed) return;
|
||||
|
||||
// Preserve sub-millisecond frame deltas. Rounding every uncapped frame
|
||||
// up to one millisecond makes script time run several times faster than
|
||||
|
|
@ -130,6 +139,7 @@ public sealed class RetailUiAutomationScriptRunner
|
|||
|
||||
if (_index >= _commands.Count && !_completed)
|
||||
{
|
||||
ReleaseHeldInputs();
|
||||
_completed = true;
|
||||
_log("UI probe script complete");
|
||||
}
|
||||
|
|
@ -151,6 +161,7 @@ public sealed class RetailUiAutomationScriptRunner
|
|||
"sleep" => DoSleep(command),
|
||||
"assert" => DoAssert(command),
|
||||
"command" => DoCommand(command),
|
||||
"input" => DoInput(command),
|
||||
_ => Stop(command, $"unknown command '{p[0]}'"),
|
||||
};
|
||||
}
|
||||
|
|
@ -302,6 +313,49 @@ public sealed class RetailUiAutomationScriptRunner
|
|||
return true;
|
||||
}
|
||||
|
||||
private bool DoInput(ScriptCommand command)
|
||||
{
|
||||
var p = command.Parts;
|
||||
if (p.Length != 3)
|
||||
return Stop(command, "usage: input press|down|up <InputAction>");
|
||||
if (!Enum.TryParse(p[2], ignoreCase: true, out InputAction action)
|
||||
|| action == InputAction.None
|
||||
|| !Enum.IsDefined(action))
|
||||
return Stop(command, $"unknown input action '{p[2]}'");
|
||||
|
||||
switch (p[1].ToLowerInvariant())
|
||||
{
|
||||
case "press":
|
||||
if (_pressInput is null)
|
||||
return Stop(command, "input press injection is unavailable");
|
||||
return _pressInput(action)
|
||||
|| Stop(command, $"input press {action} was captured");
|
||||
|
||||
case "down":
|
||||
if (_setInputHeld is null)
|
||||
return Stop(command, "input held injection is unavailable");
|
||||
if (_heldInputs.Contains(action))
|
||||
return Stop(command, $"input {action} is already down");
|
||||
if (!_setInputHeld(action, true))
|
||||
return Stop(command, $"input down {action} was captured");
|
||||
_heldInputs.Add(action);
|
||||
return true;
|
||||
|
||||
case "up":
|
||||
if (_setInputHeld is null)
|
||||
return Stop(command, "input held injection is unavailable");
|
||||
if (!_heldInputs.Contains(action))
|
||||
return Stop(command, $"input {action} is not down");
|
||||
if (!_setInputHeld(action, false))
|
||||
return Stop(command, $"input up {action} failed");
|
||||
_heldInputs.Remove(action);
|
||||
return true;
|
||||
|
||||
default:
|
||||
return Stop(command, "usage: input press|down|up <InputAction>");
|
||||
}
|
||||
}
|
||||
|
||||
private bool WaitOrTimeout(ScriptCommand command, int timeoutMs, string label)
|
||||
{
|
||||
if (!HasExceeded(timeoutMs)) return false;
|
||||
|
|
@ -334,10 +388,30 @@ public sealed class RetailUiAutomationScriptRunner
|
|||
private bool Stop(ScriptCommand command, string message)
|
||||
{
|
||||
_log($"line {command.LineNumber}: {message}; command: {command.Text}");
|
||||
ReleaseHeldInputs();
|
||||
_completed = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private void ReleaseHeldInputs()
|
||||
{
|
||||
if (_heldInputs.Count == 0 || _setInputHeld is null)
|
||||
return;
|
||||
|
||||
var snapshot = new InputAction[_heldInputs.Count];
|
||||
_heldInputs.CopyTo(snapshot);
|
||||
_heldInputs.Clear();
|
||||
foreach (InputAction action in snapshot)
|
||||
_setInputHeld(action, false);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
ReleaseHeldInputs();
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
private static string StripComment(string line)
|
||||
{
|
||||
int hash = line.IndexOf('#');
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ public sealed class InputDispatcher
|
|||
private readonly Stack<InputScope> _scopes = new();
|
||||
private InputScope? _combatScope;
|
||||
private readonly HashSet<KeyChord> _heldHoldChords = new();
|
||||
private readonly HashSet<InputAction> _automationHeldActions = new();
|
||||
|
||||
// Double-click detection. _lastMouseDownButton == null means no recent press.
|
||||
// _lastMouseDownTickMs is Environment.TickCount64 at the time of that press.
|
||||
|
|
@ -72,6 +73,52 @@ public sealed class InputDispatcher
|
|||
_mouse.Scroll += OnScroll;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostic-only semantic press injection used by the connected retail-UI
|
||||
/// automation runner. The action enters through the same multicast
|
||||
/// <see cref="Fired"/> stream as a resolved physical binding, but does not
|
||||
/// invent a keyboard chord or depend on an interactive Windows desktop.
|
||||
/// </summary>
|
||||
/// <returns><see langword="false"/> when modal/text capture owns the
|
||||
/// keyboard and a physical press would have been suppressed.</returns>
|
||||
public bool TryInvokeAutomationAction(InputAction action)
|
||||
{
|
||||
if (action == InputAction.None || !Enum.IsDefined(action))
|
||||
throw new ArgumentOutOfRangeException(nameof(action));
|
||||
if (_captureCallback is not null || _mouse.WantCaptureKeyboard)
|
||||
return false;
|
||||
|
||||
Fired?.Invoke(action, ActivationType.Press);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostic-only held-action injection. A down edge emits the same
|
||||
/// <see cref="ActivationType.Press"/> transition as a Hold binding and is
|
||||
/// visible to <see cref="IsActionHeld"/> until the matching up edge emits
|
||||
/// <see cref="ActivationType.Release"/>. This keeps unattended connected
|
||||
/// tests on the production input/movement path without OS-level key
|
||||
/// synthesis, which Windows drops when no interactive desktop is attached.
|
||||
/// </summary>
|
||||
public bool TrySetAutomationActionHeld(InputAction action, bool held)
|
||||
{
|
||||
if (action == InputAction.None || !Enum.IsDefined(action))
|
||||
throw new ArgumentOutOfRangeException(nameof(action));
|
||||
|
||||
if (held)
|
||||
{
|
||||
if (_captureCallback is not null || _mouse.WantCaptureKeyboard)
|
||||
return false;
|
||||
if (_automationHeldActions.Add(action))
|
||||
Fired?.Invoke(action, ActivationType.Press);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (_automationHeldActions.Remove(action))
|
||||
Fired?.Invoke(action, ActivationType.Release);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Topmost scope on the stack — what the dispatcher looks up first.</summary>
|
||||
public InputScope ActiveScope => _scopes.Peek() == InputScope.Game && _combatScope is { } combat
|
||||
? combat
|
||||
|
|
@ -177,6 +224,7 @@ public sealed class InputDispatcher
|
|||
// movement only — latched state that isn't a key (e.g. autorun, ORed into Forward at
|
||||
// the call site) keeps driving the character, so chat doesn't cancel autorun.
|
||||
if (_mouse.WantCaptureKeyboard) return false;
|
||||
if (_automationHeldActions.Contains(action)) return true;
|
||||
foreach (var b in _bindings.ForAction(action))
|
||||
{
|
||||
if (!IsChordHeld(b.Chord)) continue;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue