Chessistics/Scripts/Automation/AutomationHarness.cs

128 lines
4 KiB
C#
Raw Permalink Normal View History

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=&lt;dir&gt;. Polls &lt;dir&gt;/inbox/ each frame for JSON command
/// files, dispatches them, and writes results to &lt;dir&gt;/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);
}
}