Launching Godot with --automation=<dir> activates an AutomationHarness node that polls <dir>/inbox/ for JSON command files, executes them via a thin facade over existing public surfaces (GameSim, InputMapper, EventAnimator, ControlBar, PieceStockPanel), and writes results plus screenshots back to disk. The black-box simulation boundary is not crossed — every command routes through the same signals/methods a real player would trigger. A stdlib-only Python helper (tools/automation/harness.py) wraps the protocol for test scripts and interactive REPLs. Smoke test passes end-to-end: load mission, place a piece, step 10 turns, capture 14 1280x720 PNGs, handle rejections, quit cleanly. Existing 102 engine unit tests still green.
127 lines
4 KiB
C#
127 lines
4 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Text.Json.Nodes;
|
|
using System.Threading.Tasks;
|
|
using Godot;
|
|
|
|
namespace Chessistics.Scripts.Automation;
|
|
|
|
/// <summary>
|
|
/// Lives at the root of the scene tree when the game is launched with
|
|
/// --automation=<dir>. Polls <dir>/inbox/ each frame for JSON command
|
|
/// files, dispatches them, and writes results to <dir>/outbox/.
|
|
/// </summary>
|
|
public partial class AutomationHarness : Node
|
|
{
|
|
public string Root { get; }
|
|
private readonly AutomationFacade _facade;
|
|
private CommandDispatcher _dispatcher = null!;
|
|
private bool _busy;
|
|
private readonly HashSet<string> _processed = new(StringComparer.Ordinal);
|
|
|
|
internal AutomationHarness(string root, AutomationFacade facade)
|
|
{
|
|
Root = root;
|
|
_facade = facade;
|
|
Name = "AutomationHarness";
|
|
}
|
|
|
|
public override void _Ready()
|
|
{
|
|
IpcFiles.EnsureDirs(Root);
|
|
_dispatcher = new CommandDispatcher(this, _facade);
|
|
|
|
GD.Print($"[Automation] Harness ready at {Root}");
|
|
var ready = new JsonObject
|
|
{
|
|
["ready"] = true,
|
|
["pid"] = OS.GetProcessId(),
|
|
["godotVersion"] = (string)Godot.Engine.GetVersionInfo()["string"],
|
|
["viewportWidth"] = GetViewport().GetVisibleRect().Size.X,
|
|
["viewportHeight"] = GetViewport().GetVisibleRect().Size.Y,
|
|
};
|
|
IpcFiles.AtomicWrite(Path.Combine(Root, IpcFiles.ReadyFile), ready.ToJsonString());
|
|
}
|
|
|
|
public override void _Process(double delta)
|
|
{
|
|
if (_busy) return;
|
|
var next = IpcFiles.NextInbox(Root, _processed);
|
|
if (next == null) return;
|
|
|
|
_processed.Add(next);
|
|
_busy = true;
|
|
_ = ProcessCommandAsync(next);
|
|
}
|
|
|
|
private async Task ProcessCommandAsync(string inboxPath)
|
|
{
|
|
string id = Path.GetFileNameWithoutExtension(inboxPath);
|
|
JsonObject envelope;
|
|
try
|
|
{
|
|
var text = await ReadAllTextRetry(inboxPath);
|
|
envelope = (JsonObject)JsonNode.Parse(text)!;
|
|
id = envelope["id"]?.GetValue<string>() ?? id;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
GD.PrintErr($"[Automation] Cannot parse {inboxPath}: {ex.Message}");
|
|
_busy = false;
|
|
return;
|
|
}
|
|
|
|
var response = new JsonObject { ["id"] = id };
|
|
try
|
|
{
|
|
var cmd = envelope["cmd"]!.GetValue<string>();
|
|
var args = envelope["args"]?.AsObject() ?? new JsonObject();
|
|
GD.Print($"[Automation] → {cmd}");
|
|
|
|
var result = await _dispatcher.Dispatch(cmd, args);
|
|
response["ok"] = true;
|
|
response["result"] = result;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
GD.PrintErr($"[Automation] Error on {inboxPath}: {ex}");
|
|
response["ok"] = false;
|
|
response["error"] = ex.Message;
|
|
}
|
|
|
|
try
|
|
{
|
|
IpcFiles.AtomicWrite(IpcFiles.OutboxPath(Root, id), response.ToJsonString());
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
GD.PrintErr($"[Automation] Cannot write outbox for {id}: {ex}");
|
|
}
|
|
|
|
try
|
|
{
|
|
if (File.Exists(inboxPath)) File.Delete(inboxPath);
|
|
}
|
|
catch { /* best effort */ }
|
|
|
|
_busy = false;
|
|
}
|
|
|
|
/// <summary>Awaiting a signal from C#; wrapper so dispatcher can use it.</summary>
|
|
internal SignalAwaiter ToSignalAsync(GodotObject source, string signal) => ToSignal(source, signal);
|
|
|
|
/// <summary>Called via CallDeferred by the quit command.</summary>
|
|
public void RequestQuit() => GetTree().Quit();
|
|
|
|
private static async Task<string> ReadAllTextRetry(string path)
|
|
{
|
|
// The agent writes .tmp→rename atomically, but a brief race can still occur.
|
|
for (int i = 0; i < 5; i++)
|
|
{
|
|
try { return File.ReadAllText(path); }
|
|
catch (IOException) { await Task.Delay(20); }
|
|
}
|
|
return File.ReadAllText(path);
|
|
}
|
|
}
|