Add file-IPC automation harness for autonomous game testing
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.
This commit is contained in:
parent
2d1aea0a7a
commit
f86b9abecd
14 changed files with 1272 additions and 6 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -21,3 +21,6 @@ Thumbs.db
|
||||||
# Claude Code
|
# Claude Code
|
||||||
.claude/
|
.claude/
|
||||||
.idea
|
.idea
|
||||||
|
|
||||||
|
# Automation harness run outputs
|
||||||
|
.automation_runs/
|
||||||
|
|
|
||||||
60
Scripts/Automation/AutomationFacade.cs
Normal file
60
Scripts/Automation/AutomationFacade.cs
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
using System;
|
||||||
|
using Chessistics.Engine.Simulation;
|
||||||
|
using Chessistics.Scripts.Input;
|
||||||
|
using Chessistics.Scripts.Presentation;
|
||||||
|
using Chessistics.Scripts.UI;
|
||||||
|
|
||||||
|
namespace Chessistics.Scripts.Automation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thin pass-through from the automation harness to the runtime objects it needs.
|
||||||
|
/// The harness never talks to Main directly — only through this facade — so the
|
||||||
|
/// surface to audit stays tiny.
|
||||||
|
/// </summary>
|
||||||
|
internal class AutomationFacade
|
||||||
|
{
|
||||||
|
public Func<GameSim?> Sim { get; }
|
||||||
|
public InputMapper Input { get; }
|
||||||
|
public EventAnimator Animator { get; }
|
||||||
|
public PieceStockPanel Stock { get; }
|
||||||
|
public ControlBar ControlBar { get; }
|
||||||
|
|
||||||
|
public Action<string, int> LoadMission { get; }
|
||||||
|
public Action Play { get; }
|
||||||
|
public Action Pause { get; }
|
||||||
|
public Action Step { get; }
|
||||||
|
public Action TogglePlayPause { get; }
|
||||||
|
public Action BackToMenu { get; }
|
||||||
|
public Action<float> SetSpeed { get; }
|
||||||
|
public Action Quit { get; }
|
||||||
|
|
||||||
|
public AutomationFacade(
|
||||||
|
Func<GameSim?> sim,
|
||||||
|
InputMapper input,
|
||||||
|
EventAnimator animator,
|
||||||
|
PieceStockPanel stock,
|
||||||
|
ControlBar controlBar,
|
||||||
|
Action<string, int> loadMission,
|
||||||
|
Action play,
|
||||||
|
Action pause,
|
||||||
|
Action step,
|
||||||
|
Action togglePlayPause,
|
||||||
|
Action backToMenu,
|
||||||
|
Action<float> setSpeed,
|
||||||
|
Action quit)
|
||||||
|
{
|
||||||
|
Sim = sim;
|
||||||
|
Input = input;
|
||||||
|
Animator = animator;
|
||||||
|
Stock = stock;
|
||||||
|
ControlBar = controlBar;
|
||||||
|
LoadMission = loadMission;
|
||||||
|
Play = play;
|
||||||
|
Pause = pause;
|
||||||
|
Step = step;
|
||||||
|
TogglePlayPause = togglePlayPause;
|
||||||
|
BackToMenu = backToMenu;
|
||||||
|
SetSpeed = setSpeed;
|
||||||
|
Quit = quit;
|
||||||
|
}
|
||||||
|
}
|
||||||
127
Scripts/Automation/AutomationHarness.cs
Normal file
127
Scripts/Automation/AutomationHarness.cs
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
266
Scripts/Automation/CommandDispatcher.cs
Normal file
266
Scripts/Automation/CommandDispatcher.cs
Normal file
|
|
@ -0,0 +1,266 @@
|
||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using Godot;
|
||||||
|
using System.Linq;
|
||||||
|
using Chessistics.Engine.Model;
|
||||||
|
using Chessistics.Scripts.Input;
|
||||||
|
|
||||||
|
namespace Chessistics.Scripts.Automation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps command strings → handlers. Each handler returns the "result" payload;
|
||||||
|
/// the harness wraps it in the envelope.
|
||||||
|
/// </summary>
|
||||||
|
internal class CommandDispatcher
|
||||||
|
{
|
||||||
|
private readonly AutomationHarness _harness;
|
||||||
|
private readonly AutomationFacade _facade;
|
||||||
|
|
||||||
|
public CommandDispatcher(AutomationHarness harness, AutomationFacade facade)
|
||||||
|
{
|
||||||
|
_harness = harness;
|
||||||
|
_facade = facade;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<JsonNode?> Dispatch(string cmd, JsonObject args)
|
||||||
|
{
|
||||||
|
return cmd switch
|
||||||
|
{
|
||||||
|
"screenshot" => await Screenshot(args),
|
||||||
|
"get_state" => GetState(),
|
||||||
|
"select_piece" => SelectPiece(args),
|
||||||
|
"place" => Place(args),
|
||||||
|
"click_cell" => ClickCell(args),
|
||||||
|
"key" => Key(args),
|
||||||
|
"play" => Play(),
|
||||||
|
"pause" => Pause(),
|
||||||
|
"step" => await Step(),
|
||||||
|
"wait_idle" => await WaitIdle(args),
|
||||||
|
"set_speed" => SetSpeed(args),
|
||||||
|
"load_mission" => LoadMission(args),
|
||||||
|
"back_to_menu" => BackToMenu(),
|
||||||
|
"quit" => Quit(),
|
||||||
|
_ => throw new InvalidOperationException($"Unknown command: {cmd}"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- handlers ---
|
||||||
|
|
||||||
|
private async Task<JsonNode?> Screenshot(JsonObject args)
|
||||||
|
{
|
||||||
|
var name = args["name"]?.GetValue<string>() ?? "screenshot";
|
||||||
|
await _harness.ToSignalAsync(RenderingServer.Singleton, "frame_post_draw");
|
||||||
|
var image = _harness.GetViewport().GetTexture().GetImage();
|
||||||
|
var path = IpcFiles.ScreenshotPath(_harness.Root, name);
|
||||||
|
var err = image.SavePng(path);
|
||||||
|
if (err != Error.Ok)
|
||||||
|
throw new InvalidOperationException($"SavePng failed: {err}");
|
||||||
|
|
||||||
|
var result = new JsonObject
|
||||||
|
{
|
||||||
|
["path"] = Path.GetRelativePath(_harness.Root, path).Replace('\\', '/'),
|
||||||
|
["abs_path"] = path,
|
||||||
|
["width"] = image.GetWidth(),
|
||||||
|
["height"] = image.GetHeight(),
|
||||||
|
};
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonNode? GetState()
|
||||||
|
{
|
||||||
|
var sim = _facade.Sim();
|
||||||
|
if (sim == null) return new JsonObject { ["loaded"] = false };
|
||||||
|
var snap = sim.GetSnapshot();
|
||||||
|
var obj = SnapshotSerializer.Serialize(snap);
|
||||||
|
obj["loaded"] = true;
|
||||||
|
obj["animating"] = _facade.Animator.IsAnimating;
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonNode? SelectPiece(JsonObject args)
|
||||||
|
{
|
||||||
|
var kind = ParsePieceKind(args["kind"]);
|
||||||
|
_facade.Stock.SimulateSelect(kind);
|
||||||
|
return new JsonObject
|
||||||
|
{
|
||||||
|
["phase"] = _facade.Input.CurrentPhase.ToString(),
|
||||||
|
["kind"] = kind.ToString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonNode? Place(JsonObject args)
|
||||||
|
{
|
||||||
|
var sim = _facade.Sim();
|
||||||
|
if (sim == null) throw new InvalidOperationException("No simulation loaded.");
|
||||||
|
|
||||||
|
var kind = ParsePieceKind(args["kind"]);
|
||||||
|
var start = ParseCoords(args["start"]);
|
||||||
|
var end = ParseCoords(args["end"]);
|
||||||
|
|
||||||
|
// Snapshot ids before so we can identify the new piece after.
|
||||||
|
var before = sim.GetSnapshot();
|
||||||
|
var idsBefore = new HashSet<int>(before.Pieces.Select(p => p.Id));
|
||||||
|
|
||||||
|
// Route through the UI signal — this mutates the sim exactly once and
|
||||||
|
// runs the same path a human click would: HandleEditEvents, stock refresh,
|
||||||
|
// SetSnapshot on InputMapper.
|
||||||
|
_facade.Input.EmitSignal(
|
||||||
|
InputMapper.SignalName.PlacementRequested,
|
||||||
|
(int)kind, start.Col, start.Row, end.Col, end.Row);
|
||||||
|
|
||||||
|
var after = sim.GetSnapshot();
|
||||||
|
var newPiece = after.Pieces.FirstOrDefault(p => !idsBefore.Contains(p.Id));
|
||||||
|
var placed = newPiece != null;
|
||||||
|
return new JsonObject
|
||||||
|
{
|
||||||
|
["placed"] = placed,
|
||||||
|
["pieceId"] = newPiece?.Id,
|
||||||
|
["reason"] = placed ? null : "Placement rejected (check Godot console log for PlacementRejectedEvent).",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonNode? ClickCell(JsonObject args)
|
||||||
|
{
|
||||||
|
var col = args["col"]!.GetValue<int>();
|
||||||
|
var row = args["row"]!.GetValue<int>();
|
||||||
|
var button = args["button"]?.GetValue<string>()?.ToLowerInvariant() ?? "left";
|
||||||
|
var mouseBtn = button switch
|
||||||
|
{
|
||||||
|
"right" => MouseButton.Right,
|
||||||
|
_ => MouseButton.Left,
|
||||||
|
};
|
||||||
|
_facade.Input.SimulateClick(new Coords(col, row), mouseBtn);
|
||||||
|
return new JsonObject
|
||||||
|
{
|
||||||
|
["phase"] = _facade.Input.CurrentPhase.ToString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonNode? Key(JsonObject args)
|
||||||
|
{
|
||||||
|
var key = args["key"]!.GetValue<string>();
|
||||||
|
switch (key.ToLowerInvariant())
|
||||||
|
{
|
||||||
|
case "space":
|
||||||
|
_facade.TogglePlayPause();
|
||||||
|
break;
|
||||||
|
case "escape":
|
||||||
|
case "esc":
|
||||||
|
_facade.Input.Cancel();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new InvalidOperationException($"Unsupported key: {key}");
|
||||||
|
}
|
||||||
|
return new JsonObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonNode? Play()
|
||||||
|
{
|
||||||
|
_facade.Play();
|
||||||
|
return PhaseInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonNode? Pause()
|
||||||
|
{
|
||||||
|
_facade.Pause();
|
||||||
|
return PhaseInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<JsonNode?> Step()
|
||||||
|
{
|
||||||
|
_facade.Step();
|
||||||
|
// Dispatcher auto-waits for idle before writing the result.
|
||||||
|
await WaitIdleInternal(timeoutMs: 10000);
|
||||||
|
return PhaseInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<JsonNode?> WaitIdle(JsonObject args)
|
||||||
|
{
|
||||||
|
var timeout = args["timeoutMs"]?.GetValue<int>() ?? 10000;
|
||||||
|
var reached = await WaitIdleInternal(timeout);
|
||||||
|
var info = PhaseInfo()!.AsObject();
|
||||||
|
info["idle"] = reached;
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal async Task<bool> WaitIdleInternal(int timeoutMs)
|
||||||
|
{
|
||||||
|
var elapsed = 0.0;
|
||||||
|
while (_facade.Animator.IsAnimating)
|
||||||
|
{
|
||||||
|
await _harness.ToSignalAsync(_harness.GetTree(), "process_frame");
|
||||||
|
elapsed += 1.0 / 60.0 * 1000.0;
|
||||||
|
if (elapsed > timeoutMs) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonNode? SetSpeed(JsonObject args)
|
||||||
|
{
|
||||||
|
var interval = (float)args["interval"]!.GetValue<double>();
|
||||||
|
_facade.SetSpeed(interval);
|
||||||
|
return new JsonObject { ["interval"] = interval };
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonNode? LoadMission(JsonObject args)
|
||||||
|
{
|
||||||
|
var campaign = args["campaign"]?.GetValue<string>() ?? "campaign_01";
|
||||||
|
var index = args["missionIndex"]?.GetValue<int>() ?? 0;
|
||||||
|
_facade.LoadMission(campaign, index);
|
||||||
|
|
||||||
|
var sim = _facade.Sim();
|
||||||
|
if (sim == null) return new JsonObject { ["loaded"] = false };
|
||||||
|
return SnapshotSerializer.Serialize(sim.GetSnapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonNode? BackToMenu()
|
||||||
|
{
|
||||||
|
_facade.BackToMenu();
|
||||||
|
return new JsonObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonNode? Quit()
|
||||||
|
{
|
||||||
|
// Defer so we can write the result first.
|
||||||
|
_harness.CallDeferred(nameof(AutomationHarness.RequestQuit));
|
||||||
|
return new JsonObject { ["quitting"] = true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers ---
|
||||||
|
|
||||||
|
private JsonNode PhaseInfo()
|
||||||
|
{
|
||||||
|
var sim = _facade.Sim();
|
||||||
|
var obj = new JsonObject();
|
||||||
|
if (sim != null)
|
||||||
|
{
|
||||||
|
var snap = sim.GetSnapshot();
|
||||||
|
obj["phase"] = snap.Phase.ToString();
|
||||||
|
obj["turn"] = snap.TurnNumber;
|
||||||
|
obj["missionIndex"] = snap.Campaign?.CurrentMissionIndex;
|
||||||
|
}
|
||||||
|
obj["animating"] = _facade.Animator.IsAnimating;
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PieceKind ParsePieceKind(JsonNode? node)
|
||||||
|
{
|
||||||
|
if (node == null) throw new InvalidOperationException("Missing 'kind'.");
|
||||||
|
var val = node.AsValue();
|
||||||
|
if (val.TryGetValue<int>(out var i)) return (PieceKind)i;
|
||||||
|
var s = val.GetValue<string>();
|
||||||
|
if (Enum.TryParse<PieceKind>(s, ignoreCase: true, out var k)) return k;
|
||||||
|
throw new InvalidOperationException($"Unknown piece kind: {s}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Coords ParseCoords(JsonNode? node)
|
||||||
|
{
|
||||||
|
if (node is JsonArray arr && arr.Count == 2)
|
||||||
|
return new Coords(arr[0]!.GetValue<int>(), arr[1]!.GetValue<int>());
|
||||||
|
if (node is JsonObject obj && obj.ContainsKey("col") && obj.ContainsKey("row"))
|
||||||
|
return new Coords(obj["col"]!.GetValue<int>(), obj["row"]!.GetValue<int>());
|
||||||
|
throw new InvalidOperationException("Coords must be [col,row] or {col,row}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
65
Scripts/Automation/IpcFiles.cs
Normal file
65
Scripts/Automation/IpcFiles.cs
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace Chessistics.Scripts.Automation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tiny helpers for the file-based IPC. All writes are atomic: write .tmp then rename.
|
||||||
|
/// </summary>
|
||||||
|
internal static class IpcFiles
|
||||||
|
{
|
||||||
|
public const string InboxDir = "inbox";
|
||||||
|
public const string OutboxDir = "outbox";
|
||||||
|
public const string ScreensDir = "screens";
|
||||||
|
public const string ReadyFile = "ready.json";
|
||||||
|
public const string LogFile = "harness.log";
|
||||||
|
|
||||||
|
public static void EnsureDirs(string root)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(root);
|
||||||
|
Directory.CreateDirectory(Path.Combine(root, InboxDir));
|
||||||
|
Directory.CreateDirectory(Path.Combine(root, OutboxDir));
|
||||||
|
Directory.CreateDirectory(Path.Combine(root, ScreensDir));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void AtomicWrite(string path, string contents)
|
||||||
|
{
|
||||||
|
var tmp = path + ".tmp";
|
||||||
|
File.WriteAllText(tmp, contents);
|
||||||
|
if (File.Exists(path)) File.Delete(path);
|
||||||
|
File.Move(tmp, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Return the oldest inbox file whose id hasn't been processed yet, or null.
|
||||||
|
/// Sorted by filename — agents should name files with a monotonic prefix for ordering.
|
||||||
|
/// </summary>
|
||||||
|
public static string? NextInbox(string root, HashSet<string> processed)
|
||||||
|
{
|
||||||
|
var inbox = Path.Combine(root, InboxDir);
|
||||||
|
if (!Directory.Exists(inbox)) return null;
|
||||||
|
|
||||||
|
var files = Directory.GetFiles(inbox, "*.json")
|
||||||
|
.Where(f => !f.EndsWith(".tmp.json", StringComparison.Ordinal))
|
||||||
|
.OrderBy(f => Path.GetFileName(f), StringComparer.Ordinal)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var f in files)
|
||||||
|
{
|
||||||
|
if (!processed.Contains(f))
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string OutboxPath(string root, string id) => Path.Combine(root, OutboxDir, id + ".json");
|
||||||
|
|
||||||
|
public static string ScreenshotPath(string root, string name)
|
||||||
|
{
|
||||||
|
var safe = name.Replace('/', '_').Replace('\\', '_').Replace(':', '_');
|
||||||
|
if (!safe.EndsWith(".png", StringComparison.OrdinalIgnoreCase)) safe += ".png";
|
||||||
|
return Path.Combine(root, ScreensDir, safe);
|
||||||
|
}
|
||||||
|
}
|
||||||
123
Scripts/Automation/SnapshotSerializer.cs
Normal file
123
Scripts/Automation/SnapshotSerializer.cs
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using Chessistics.Engine.Model;
|
||||||
|
|
||||||
|
namespace Chessistics.Scripts.Automation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serializes BoardSnapshot into plain JSON so the agent can reason about game state.
|
||||||
|
/// </summary>
|
||||||
|
internal static class SnapshotSerializer
|
||||||
|
{
|
||||||
|
public static JsonObject Serialize(BoardSnapshot snap)
|
||||||
|
{
|
||||||
|
var root = new JsonObject
|
||||||
|
{
|
||||||
|
["phase"] = snap.Phase.ToString(),
|
||||||
|
["turn"] = snap.TurnNumber,
|
||||||
|
["width"] = snap.Width,
|
||||||
|
["height"] = snap.Height,
|
||||||
|
["grid"] = SerializeGrid(snap.Grid, snap.Width, snap.Height),
|
||||||
|
["productions"] = new JsonArray(snap.Productions.Select(SerializeProd).ToArray<JsonNode?>()),
|
||||||
|
["demands"] = new JsonArray(snap.Demands.Select(SerializeDemand).ToArray<JsonNode?>()),
|
||||||
|
["transformers"] = new JsonArray(snap.Transformers.Select(SerializeTransformer).ToArray<JsonNode?>()),
|
||||||
|
["pieces"] = new JsonArray(snap.Pieces.Select(SerializePiece).ToArray<JsonNode?>()),
|
||||||
|
["remainingStock"] = SerializeStock(snap.RemainingStock),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (snap.Campaign != null)
|
||||||
|
{
|
||||||
|
root["campaign"] = new JsonObject
|
||||||
|
{
|
||||||
|
["name"] = snap.Campaign.Name,
|
||||||
|
["currentMissionIndex"] = snap.Campaign.CurrentMissionIndex,
|
||||||
|
["completedMissions"] = new JsonArray(snap.Campaign.CompletedMissions.Select(i => (JsonNode?)JsonValue.Create(i)).ToArray()),
|
||||||
|
["availablePieceKinds"] = new JsonArray(snap.Campaign.AvailablePieceKinds.Select(k => (JsonNode?)JsonValue.Create(k.ToString())).ToArray()),
|
||||||
|
["availableLevels"] = new JsonArray(snap.Campaign.AvailableLevels
|
||||||
|
.Select(u => (JsonNode?)new JsonObject { ["kind"] = u.Kind.ToString(), ["level"] = u.Level })
|
||||||
|
.ToArray()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
root["campaign"] = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonArray SerializeGrid(CellType[,] grid, int w, int h)
|
||||||
|
{
|
||||||
|
var rows = new JsonArray();
|
||||||
|
for (int r = 0; r < h; r++)
|
||||||
|
{
|
||||||
|
var row = new JsonArray();
|
||||||
|
for (int c = 0; c < w; c++)
|
||||||
|
row.Add(grid[c, r].ToString());
|
||||||
|
rows.Add(row);
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonObject SerializeProd(ProductionSnapshot p) => new()
|
||||||
|
{
|
||||||
|
["pos"] = CoordsJson(p.Position),
|
||||||
|
["name"] = p.Name,
|
||||||
|
["cargo"] = p.Cargo.ToString(),
|
||||||
|
["amount"] = p.Amount,
|
||||||
|
["buffer"] = p.BufferCount,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static JsonObject SerializeDemand(DemandSnapshot d) => new()
|
||||||
|
{
|
||||||
|
["pos"] = CoordsJson(d.Position),
|
||||||
|
["name"] = d.Name,
|
||||||
|
["cargo"] = d.Cargo.ToString(),
|
||||||
|
["required"] = d.Required,
|
||||||
|
["deadline"] = d.Deadline,
|
||||||
|
["received"] = d.ReceivedCount,
|
||||||
|
["satisfied"] = d.IsSatisfied,
|
||||||
|
["missionIndex"] = d.MissionIndex,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static JsonObject SerializeTransformer(TransformerSnapshot t) => new()
|
||||||
|
{
|
||||||
|
["pos"] = CoordsJson(t.Position),
|
||||||
|
["name"] = t.Name,
|
||||||
|
["inputCargo"] = t.InputCargo.ToString(),
|
||||||
|
["inputRequired"] = t.InputRequired,
|
||||||
|
["outputCargo"] = t.OutputCargo.ToString(),
|
||||||
|
["outputAmount"] = t.OutputAmount,
|
||||||
|
["inputBuffer"] = t.InputBufferCount,
|
||||||
|
["outputBuffer"] = t.OutputBufferCount,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static JsonObject SerializePiece(PieceSnapshot p) => new()
|
||||||
|
{
|
||||||
|
["id"] = p.Id,
|
||||||
|
["kind"] = p.Kind.ToString(),
|
||||||
|
["level"] = p.Level,
|
||||||
|
["start"] = CoordsJson(p.StartCell),
|
||||||
|
["end"] = CoordsJson(p.EndCell),
|
||||||
|
["current"] = CoordsJson(p.CurrentCell),
|
||||||
|
["cargo"] = p.Cargo?.ToString(),
|
||||||
|
["cargoFilter"] = p.CargoFilter?.ToString(),
|
||||||
|
["socialStatus"] = p.SocialStatus,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static JsonObject SerializeStock(IReadOnlyDictionary<PieceKind, int> stock)
|
||||||
|
{
|
||||||
|
var obj = new JsonObject();
|
||||||
|
foreach (var (k, v) in stock) obj[k.ToString()] = v;
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonArray CoordsJson(Coords c)
|
||||||
|
{
|
||||||
|
var arr = new JsonArray();
|
||||||
|
arr.Add(c.Col);
|
||||||
|
arr.Add(c.Row);
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -101,22 +101,41 @@ public partial class InputMapper : Node
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
HandleClickAt(coords.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleClickAt(Coords coords)
|
||||||
|
{
|
||||||
switch (_phase)
|
switch (_phase)
|
||||||
{
|
{
|
||||||
case PlacementPhase.SelectingStart:
|
case PlacementPhase.SelectingStart:
|
||||||
OnStartSelected(coords.Value);
|
OnStartSelected(coords);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case PlacementPhase.SelectingEnd:
|
case PlacementPhase.SelectingEnd:
|
||||||
OnEndSelected(coords.Value);
|
OnEndSelected(coords);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
EmitSignal(SignalName.CellClicked, coords.Value.Col, coords.Value.Row);
|
EmitSignal(SignalName.CellClicked, coords.Col, coords.Row);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Same effect as a left/right click on a board cell, for automation.
|
||||||
|
/// Runs the exact branch HandleLeftClick runs (no InputEvent synthesis).
|
||||||
|
/// </summary>
|
||||||
|
public void SimulateClick(Coords coords, MouseButton button)
|
||||||
|
{
|
||||||
|
if (button == MouseButton.Right)
|
||||||
|
{
|
||||||
|
Cancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
HandleClickAt(coords);
|
||||||
|
}
|
||||||
|
|
||||||
private void OnStartSelected(Coords start)
|
private void OnStartSelected(Coords start)
|
||||||
{
|
{
|
||||||
if (_selectedKind == null || _snapshot == null)
|
if (_selectedKind == null || _snapshot == null)
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ using Chessistics.Engine.Events;
|
||||||
using Chessistics.Engine.Loading;
|
using Chessistics.Engine.Loading;
|
||||||
using Chessistics.Engine.Model;
|
using Chessistics.Engine.Model;
|
||||||
using Chessistics.Engine.Simulation;
|
using Chessistics.Engine.Simulation;
|
||||||
|
using Chessistics.Scripts.Automation;
|
||||||
using Chessistics.Scripts.Board;
|
using Chessistics.Scripts.Board;
|
||||||
using Chessistics.Scripts.Input;
|
using Chessistics.Scripts.Input;
|
||||||
using Chessistics.Scripts.Pieces;
|
using Chessistics.Scripts.Pieces;
|
||||||
|
|
@ -55,16 +56,95 @@ public partial class Main : Node2D
|
||||||
|
|
||||||
private static readonly Color BackgroundColor = new("#2D2D2D");
|
private static readonly Color BackgroundColor = new("#2D2D2D");
|
||||||
|
|
||||||
|
// Automation harness (active only when --automation=<dir> CLI flag is given)
|
||||||
|
private string? _automationDir;
|
||||||
|
private AutomationHarness? _automationHarness;
|
||||||
|
|
||||||
public override void _Ready()
|
public override void _Ready()
|
||||||
{
|
{
|
||||||
RenderingServer.SetDefaultClearColor(BackgroundColor);
|
RenderingServer.SetDefaultClearColor(BackgroundColor);
|
||||||
|
|
||||||
|
_automationDir = ParseAutomationArg();
|
||||||
|
|
||||||
BuildSceneTree();
|
BuildSceneTree();
|
||||||
ConnectSignals();
|
ConnectSignals();
|
||||||
ShowTitleScreen();
|
ShowTitleScreen();
|
||||||
|
|
||||||
|
if (_automationDir != null)
|
||||||
|
{
|
||||||
|
// Skip the opening fade so the harness sees a stable frame immediately.
|
||||||
|
_fadeOverlay.Color = new Color(0, 0, 0, 0);
|
||||||
|
MountAutomationHarness(_automationDir);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
FadeIn(0.5f);
|
FadeIn(0.5f);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ParseAutomationArg()
|
||||||
|
{
|
||||||
|
foreach (var arg in OS.GetCmdlineArgs())
|
||||||
|
{
|
||||||
|
if (arg.StartsWith("--automation=", StringComparison.Ordinal))
|
||||||
|
return arg.Substring("--automation=".Length);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void MountAutomationHarness(string dir)
|
||||||
|
{
|
||||||
|
var facade = new AutomationFacade(
|
||||||
|
sim: () => _sim,
|
||||||
|
input: _inputMapper,
|
||||||
|
animator: _eventAnimator,
|
||||||
|
stock: _pieceStockPanel,
|
||||||
|
controlBar: _controlBar,
|
||||||
|
loadMission: HarnessLoadMission,
|
||||||
|
play: OnPlay,
|
||||||
|
pause: OnPause,
|
||||||
|
step: OnStep,
|
||||||
|
togglePlayPause: TogglePlayPause,
|
||||||
|
backToMenu: HarnessBackToMenu,
|
||||||
|
setSpeed: HarnessSetSpeed,
|
||||||
|
quit: () => GetTree().Quit());
|
||||||
|
|
||||||
|
_automationHarness = new AutomationHarness(dir, facade);
|
||||||
|
AddChild(_automationHarness);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HarnessLoadMission(string campaignName, int missionIndex)
|
||||||
|
{
|
||||||
|
var path = $"res://Data/campaigns/{campaignName}.json";
|
||||||
|
LoadCampaignDirect(path);
|
||||||
|
|
||||||
|
// Fast-forward to the requested mission by completing prior ones synthetically.
|
||||||
|
while (_sim != null && _campaignDef != null
|
||||||
|
&& _sim.GetSnapshot().Campaign is { } campSnap
|
||||||
|
&& campSnap.CurrentMissionIndex < missionIndex)
|
||||||
|
{
|
||||||
|
_sim.ProcessCommand(new PauseSimulationCommand()); // no-op if already paused
|
||||||
|
// Cheat the phase to MissionComplete then Advance — only valid in automation.
|
||||||
|
// Simpler: refuse non-zero missionIndex for now and require AdvanceMission
|
||||||
|
// via gameplay. Breaking the break keeps harness predictable.
|
||||||
|
GD.PrintErr($"[Automation] missionIndex > 0 not supported yet; staying at mission {campSnap.CurrentMissionIndex}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HarnessBackToMenu()
|
||||||
|
{
|
||||||
|
_running = false;
|
||||||
|
_simTimer.Stop();
|
||||||
|
_eventAnimator.ClearAll();
|
||||||
|
ShowTitleScreen();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HarnessSetSpeed(float interval)
|
||||||
|
{
|
||||||
|
_simInterval = interval;
|
||||||
|
OnSpeedChanged(interval);
|
||||||
|
}
|
||||||
|
|
||||||
public override void _UnhandledInput(InputEvent @event)
|
public override void _UnhandledInput(InputEvent @event)
|
||||||
{
|
{
|
||||||
|
|
@ -377,9 +457,10 @@ public partial class Main : Node2D
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private void LoadCampaign()
|
private void LoadCampaign() => LoadCampaignDirect("res://Data/campaigns/campaign_01.json");
|
||||||
|
|
||||||
|
private void LoadCampaignDirect(string path)
|
||||||
{
|
{
|
||||||
var path = "res://Data/campaigns/campaign_01.json";
|
|
||||||
var file = Godot.FileAccess.Open(path, Godot.FileAccess.ModeFlags.Read);
|
var file = Godot.FileAccess.Open(path, Godot.FileAccess.ModeFlags.Read);
|
||||||
if (file == null)
|
if (file == null)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -149,6 +149,13 @@ public partial class PieceStockPanel : VBoxContainer
|
||||||
UpdateButtonStates();
|
UpdateButtonStates();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Automation hook — runs the same path as clicking a piece button.</summary>
|
||||||
|
public void SimulateSelect(PieceKind kind)
|
||||||
|
{
|
||||||
|
if (!_entries.ContainsKey(kind)) return;
|
||||||
|
OnPieceButtonPressed(kind);
|
||||||
|
}
|
||||||
|
|
||||||
private static string GetPieceName(PieceKind kind) => kind switch
|
private static string GetPieceName(PieceKind kind) => kind switch
|
||||||
{
|
{
|
||||||
PieceKind.Pawn => "Pion",
|
PieceKind.Pawn => "Pion",
|
||||||
|
|
|
||||||
96
tools/automation/README.md
Normal file
96
tools/automation/README.md
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
# Chessistics automation harness
|
||||||
|
|
||||||
|
Drive a running Chessistics build via file-based IPC, so an AI agent (or a
|
||||||
|
scripted test) can take screenshots, inject inputs, and read game state
|
||||||
|
without a human in the loop.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
- Launch Godot with `--automation=<dir>`. The game activates an
|
||||||
|
`AutomationHarness` node that polls `<dir>/inbox/` each frame.
|
||||||
|
- Send commands as JSON files; the game writes results to `<dir>/outbox/`
|
||||||
|
and screenshots to `<dir>/screens/`.
|
||||||
|
- A handshake file `<dir>/ready.json` is written on startup.
|
||||||
|
|
||||||
|
Without the flag, the harness is not instantiated — zero runtime overhead
|
||||||
|
and zero behavior change for normal play.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From repo root
|
||||||
|
python tools/automation/smoke.py # end-to-end smoke test
|
||||||
|
python tools/automation/run_game.py # interactive REPL
|
||||||
|
```
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
- Python 3.10+ (stdlib only)
|
||||||
|
- Godot 4.6 mono build at `C:\Apps\godot\Godot_v4.6.2-stable_mono_win64_console.exe`
|
||||||
|
(override with `Harness(godot_exe=...)` or edit the default in `harness.py`).
|
||||||
|
- A compiled build: run `dotnet build Chessistics.csproj` first.
|
||||||
|
|
||||||
|
## Python API
|
||||||
|
|
||||||
|
```python
|
||||||
|
from tools.automation.harness import Harness
|
||||||
|
|
||||||
|
with Harness.launch() as h:
|
||||||
|
h.load_mission("campaign_01", 0)
|
||||||
|
state = h.state() # full snapshot as dict
|
||||||
|
h.screenshot("before") # → .automation_runs/<ts>/screens/before.png
|
||||||
|
h.place("Rook", (0, 0), (0, 3)) # place a piece
|
||||||
|
h.step() # one simulation tick (auto-waits for animation)
|
||||||
|
h.screenshot("after")
|
||||||
|
h.set_speed(0.1); h.play() # auto-run fast
|
||||||
|
```
|
||||||
|
|
||||||
|
Methods on `Harness`:
|
||||||
|
- `screenshot(name) -> Path`
|
||||||
|
- `state() -> dict` — full snapshot + `animating` flag
|
||||||
|
- `select(kind)` — e.g. `"Rook"`, `"Knight"`
|
||||||
|
- `place(kind, start, end, level=1)` — returns `{placed, pieceId, reason}`
|
||||||
|
- `click_cell(col, row, button="left"|"right")`
|
||||||
|
- `key(name)` — `"Space"` (play/pause), `"Escape"` (cancel)
|
||||||
|
- `play()` / `pause()` / `step()` / `wait_idle()`
|
||||||
|
- `set_speed(interval_seconds)` — auto-step interval
|
||||||
|
- `load_mission(campaign, missionIndex=0)`
|
||||||
|
- `back_to_menu()` / `quit()`
|
||||||
|
|
||||||
|
Every non-query command auto-waits for `EventAnimator.IsAnimating == false`
|
||||||
|
before returning, so consecutive calls see a fully-settled state.
|
||||||
|
|
||||||
|
## JSON protocol
|
||||||
|
|
||||||
|
Inbox: `{ "id": "<uuid>", "cmd": "...", "args": {...} }`
|
||||||
|
Outbox: `{ "id": "<uuid>", "ok": true, "result": {...} }` or
|
||||||
|
`{ "id": "<uuid>", "ok": false, "error": "..." }`
|
||||||
|
|
||||||
|
See the `cmd` table in `Scripts/Automation/CommandDispatcher.cs` for the
|
||||||
|
complete list.
|
||||||
|
|
||||||
|
## Output locations
|
||||||
|
|
||||||
|
- `.automation_runs/<name>/ready.json` — handshake
|
||||||
|
- `.automation_runs/<name>/inbox/`, `outbox/` — command queue
|
||||||
|
- `.automation_runs/<name>/screens/*.png` — 1280x720 PNGs
|
||||||
|
|
||||||
|
`.automation_runs/` is a good candidate for `.gitignore` (not added here
|
||||||
|
automatically — add it manually if needed).
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- **Godot exits before ready.json** — build probably didn't compile, or the
|
||||||
|
`--path` arg is wrong. Run `dotnet build Chessistics.csproj` and check
|
||||||
|
stderr from the harness.
|
||||||
|
- **Screenshot is all black** — `--headless` was passed; the harness needs
|
||||||
|
a real rendering context. Don't use `--headless` with automation.
|
||||||
|
- **Command times out** — an animation may be stuck; check the Godot
|
||||||
|
console log for errors.
|
||||||
|
|
||||||
|
## Architecture notes
|
||||||
|
|
||||||
|
The harness sits behind a thin facade (`AutomationFacade`) that the
|
||||||
|
dispatcher uses. It never reaches into engine internals — only calls
|
||||||
|
existing public surfaces on `GameSim`, `InputMapper`, `EventAnimator`,
|
||||||
|
`ControlBar`, `PieceStockPanel`. The black-box simulation separation
|
||||||
|
stays intact.
|
||||||
BIN
tools/automation/__pycache__/harness.cpython-313.pyc
Normal file
BIN
tools/automation/__pycache__/harness.cpython-313.pyc
Normal file
Binary file not shown.
272
tools/automation/harness.py
Normal file
272
tools/automation/harness.py
Normal file
|
|
@ -0,0 +1,272 @@
|
||||||
|
"""
|
||||||
|
Thin Python wrapper around the file-based Chessistics automation IPC.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from harness import Harness
|
||||||
|
with Harness.launch() as h:
|
||||||
|
h.load_mission("campaign_01", 0)
|
||||||
|
h.screenshot("00_initial")
|
||||||
|
print(h.state()["phase"])
|
||||||
|
h.place("Rook", (0, 0), (0, 3))
|
||||||
|
h.step()
|
||||||
|
h.screenshot("01_after_step")
|
||||||
|
|
||||||
|
No third-party dependencies — stdlib only.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# Guard against user-site `json_extensions` namespace packages that shadow the
|
||||||
|
# stdlib json module. Harmless if nothing is shadowing.
|
||||||
|
import sys as _sys
|
||||||
|
for _k in [k for k in list(_sys.modules) if k == "json" or k.startswith("json.")]:
|
||||||
|
if _sys.modules[_k].__file__ is None: # namespace package → purge
|
||||||
|
del _sys.modules[_k]
|
||||||
|
_sys.path[:] = [p for p in _sys.path if "Roaming\\Python" not in p and "Roaming/Python" not in p]
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# Resolve defaults relative to the repo root (parent of tools/).
|
||||||
|
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
_DEFAULT_GODOT = Path(r"C:\Apps\godot\Godot_v4.6.2-stable_mono_win64_console.exe")
|
||||||
|
_DEFAULT_RUNS = _REPO_ROOT / ".automation_runs"
|
||||||
|
|
||||||
|
|
||||||
|
class HarnessError(RuntimeError):
|
||||||
|
"""Raised when a command fails or times out."""
|
||||||
|
|
||||||
|
|
||||||
|
class Harness:
|
||||||
|
"""Drives a running Chessistics build via file-based IPC.
|
||||||
|
|
||||||
|
The game writes `<root>/ready.json` when the automation node is live,
|
||||||
|
reads commands from `<root>/inbox/<id>.json`, and writes results to
|
||||||
|
`<root>/outbox/<id>.json`. Screenshots land in `<root>/screens/`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
root: Path,
|
||||||
|
godot_exe: Path | None = None,
|
||||||
|
project_path: Path | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.root = Path(root).resolve()
|
||||||
|
self.godot_exe = Path(godot_exe or _DEFAULT_GODOT)
|
||||||
|
self.project_path = Path(project_path or _REPO_ROOT)
|
||||||
|
self.inbox = self.root / "inbox"
|
||||||
|
self.outbox = self.root / "outbox"
|
||||||
|
self.screens = self.root / "screens"
|
||||||
|
self.ready_file = self.root / "ready.json"
|
||||||
|
self._proc: subprocess.Popen[bytes] | None = None
|
||||||
|
self._seq = 0
|
||||||
|
|
||||||
|
# ---------------- lifecycle ----------------
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def launch(
|
||||||
|
cls,
|
||||||
|
run_name: str | None = None,
|
||||||
|
godot_exe: Path | None = None,
|
||||||
|
project_path: Path | None = None,
|
||||||
|
ready_timeout: float = 20.0,
|
||||||
|
) -> "Harness":
|
||||||
|
name = run_name or time.strftime("%Y%m%d_%H%M%S")
|
||||||
|
root = _DEFAULT_RUNS / name
|
||||||
|
h = cls(root=root, godot_exe=godot_exe, project_path=project_path)
|
||||||
|
h.start(ready_timeout=ready_timeout)
|
||||||
|
return h
|
||||||
|
|
||||||
|
def start(self, ready_timeout: float = 20.0) -> None:
|
||||||
|
# Prepare directories and wipe stale state.
|
||||||
|
for d in (self.inbox, self.outbox, self.screens):
|
||||||
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._clear_dir(self.inbox)
|
||||||
|
self._clear_dir(self.outbox)
|
||||||
|
if self.ready_file.exists():
|
||||||
|
self.ready_file.unlink()
|
||||||
|
|
||||||
|
if not self.godot_exe.exists():
|
||||||
|
raise HarnessError(f"Godot executable not found: {self.godot_exe}")
|
||||||
|
if not self.project_path.exists():
|
||||||
|
raise HarnessError(f"Project path not found: {self.project_path}")
|
||||||
|
|
||||||
|
args = [
|
||||||
|
str(self.godot_exe),
|
||||||
|
"--path", str(self.project_path),
|
||||||
|
f"--automation={self.root}",
|
||||||
|
]
|
||||||
|
print(f"[harness] launching: {' '.join(args)}", file=sys.stderr)
|
||||||
|
# Inherit stdout/stderr so GD.Print output is visible.
|
||||||
|
self._proc = subprocess.Popen(args)
|
||||||
|
|
||||||
|
# Wait for ready.json handshake.
|
||||||
|
deadline = time.time() + ready_timeout
|
||||||
|
while time.time() < deadline:
|
||||||
|
if self.ready_file.exists():
|
||||||
|
try:
|
||||||
|
info = json.loads(self.ready_file.read_text())
|
||||||
|
print(f"[harness] ready: {info}", file=sys.stderr)
|
||||||
|
return
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
if self._proc.poll() is not None:
|
||||||
|
raise HarnessError(
|
||||||
|
f"Godot exited before ready (code={self._proc.returncode})."
|
||||||
|
)
|
||||||
|
time.sleep(0.1)
|
||||||
|
raise HarnessError(f"Timed out waiting for ready.json after {ready_timeout}s.")
|
||||||
|
|
||||||
|
def close(self, timeout: float = 5.0) -> None:
|
||||||
|
if self._proc is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
if self._proc.poll() is None:
|
||||||
|
# Send quit command if still alive.
|
||||||
|
try:
|
||||||
|
self.send("quit", timeout=2.0)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while time.time() < deadline and self._proc.poll() is None:
|
||||||
|
time.sleep(0.1)
|
||||||
|
if self._proc.poll() is None:
|
||||||
|
self._proc.terminate()
|
||||||
|
self._proc.wait(timeout=3.0)
|
||||||
|
finally:
|
||||||
|
self._proc = None
|
||||||
|
|
||||||
|
def __enter__(self) -> "Harness":
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_exc) -> None:
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
# ---------------- low-level send ----------------
|
||||||
|
|
||||||
|
def send(
|
||||||
|
self,
|
||||||
|
cmd: str,
|
||||||
|
args: dict[str, Any] | None = None,
|
||||||
|
timeout: float = 15.0,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
self._seq += 1
|
||||||
|
cmd_id = f"{self._seq:06d}-{uuid.uuid4().hex[:8]}"
|
||||||
|
envelope = {"id": cmd_id, "cmd": cmd, "args": args or {}}
|
||||||
|
|
||||||
|
inbox_path = self.inbox / f"{cmd_id}.json"
|
||||||
|
outbox_path = self.outbox / f"{cmd_id}.json"
|
||||||
|
tmp_path = inbox_path.with_suffix(".json.tmp")
|
||||||
|
tmp_path.write_text(json.dumps(envelope))
|
||||||
|
os.replace(tmp_path, inbox_path)
|
||||||
|
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while time.time() < deadline:
|
||||||
|
if outbox_path.exists():
|
||||||
|
try:
|
||||||
|
response = json.loads(outbox_path.read_text())
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
time.sleep(0.05)
|
||||||
|
continue
|
||||||
|
outbox_path.unlink(missing_ok=True)
|
||||||
|
if not response.get("ok"):
|
||||||
|
raise HarnessError(
|
||||||
|
f"{cmd} failed: {response.get('error', response)}"
|
||||||
|
)
|
||||||
|
return response.get("result") or {}
|
||||||
|
if self._proc and self._proc.poll() is not None:
|
||||||
|
raise HarnessError(
|
||||||
|
f"Godot exited during {cmd} (code={self._proc.returncode})."
|
||||||
|
)
|
||||||
|
time.sleep(0.05)
|
||||||
|
raise HarnessError(f"Timed out waiting for {cmd} result after {timeout}s.")
|
||||||
|
|
||||||
|
# ---------------- convenience methods ----------------
|
||||||
|
|
||||||
|
def screenshot(self, name: str) -> Path:
|
||||||
|
result = self.send("screenshot", {"name": name})
|
||||||
|
return Path(result["abs_path"])
|
||||||
|
|
||||||
|
def state(self) -> dict[str, Any]:
|
||||||
|
return self.send("get_state")
|
||||||
|
|
||||||
|
def select(self, kind: str) -> dict[str, Any]:
|
||||||
|
return self.send("select_piece", {"kind": kind})
|
||||||
|
|
||||||
|
def place(
|
||||||
|
self,
|
||||||
|
kind: str,
|
||||||
|
start: tuple[int, int],
|
||||||
|
end: tuple[int, int],
|
||||||
|
level: int = 1,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return self.send("place", {
|
||||||
|
"kind": kind,
|
||||||
|
"start": list(start),
|
||||||
|
"end": list(end),
|
||||||
|
"level": level,
|
||||||
|
})
|
||||||
|
|
||||||
|
def click_cell(self, col: int, row: int, button: str = "left") -> dict[str, Any]:
|
||||||
|
return self.send("click_cell", {"col": col, "row": row, "button": button})
|
||||||
|
|
||||||
|
def key(self, key_name: str) -> dict[str, Any]:
|
||||||
|
return self.send("key", {"key": key_name})
|
||||||
|
|
||||||
|
def play(self) -> dict[str, Any]:
|
||||||
|
return self.send("play")
|
||||||
|
|
||||||
|
def pause(self) -> dict[str, Any]:
|
||||||
|
return self.send("pause")
|
||||||
|
|
||||||
|
def step(self) -> dict[str, Any]:
|
||||||
|
return self.send("step", timeout=20.0)
|
||||||
|
|
||||||
|
def wait_idle(self, timeout_ms: int = 10000) -> dict[str, Any]:
|
||||||
|
return self.send("wait_idle", {"timeoutMs": timeout_ms})
|
||||||
|
|
||||||
|
def set_speed(self, interval: float) -> dict[str, Any]:
|
||||||
|
return self.send("set_speed", {"interval": interval})
|
||||||
|
|
||||||
|
def load_mission(self, campaign: str = "campaign_01", index: int = 0) -> dict[str, Any]:
|
||||||
|
return self.send("load_mission", {"campaign": campaign, "missionIndex": index}, timeout=20.0)
|
||||||
|
|
||||||
|
def back_to_menu(self) -> dict[str, Any]:
|
||||||
|
return self.send("back_to_menu")
|
||||||
|
|
||||||
|
def quit(self) -> dict[str, Any]:
|
||||||
|
return self.send("quit", timeout=5.0)
|
||||||
|
|
||||||
|
# ---------------- private helpers ----------------
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _clear_dir(p: Path) -> None:
|
||||||
|
for f in p.iterdir() if p.exists() else []:
|
||||||
|
try:
|
||||||
|
f.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def launched(**kwargs):
|
||||||
|
"""Convenience context manager: `with launched() as h: ...`."""
|
||||||
|
h = Harness.launch(**kwargs)
|
||||||
|
try:
|
||||||
|
yield h
|
||||||
|
finally:
|
||||||
|
h.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Tiny REPL for manual testing.
|
||||||
|
with Harness.launch() as h:
|
||||||
|
print("Ready.", h.root)
|
||||||
|
print("State:", json.dumps(h.state(), indent=2))
|
||||||
33
tools/automation/run_game.py
Normal file
33
tools/automation/run_game.py
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
"""Launch a Chessistics build with the automation harness enabled and drop
|
||||||
|
into an interactive Python REPL.
|
||||||
|
|
||||||
|
python tools/automation/run_game.py
|
||||||
|
|
||||||
|
Then at the prompt: `h.load_mission()`, `h.state()`, `h.screenshot("foo")`...
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import code
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from harness import Harness
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
h = Harness.launch(run_name="repl")
|
||||||
|
try:
|
||||||
|
print(f"\nHarness launched. Working directory: {h.root}")
|
||||||
|
print("Ready-to-use object: `h` (see harness.py for the full API)\n")
|
||||||
|
banner = "Chessistics automation REPL — type h.<tab> for commands. Ctrl-D to quit."
|
||||||
|
local = {"h": h}
|
||||||
|
code.interact(banner=banner, local=local)
|
||||||
|
finally:
|
||||||
|
h.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
sys.exit(130)
|
||||||
114
tools/automation/smoke.py
Normal file
114
tools/automation/smoke.py
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
"""End-to-end smoke test for the automation harness.
|
||||||
|
|
||||||
|
Runs a scripted playthrough: load mission 0, place a rook, take screenshots,
|
||||||
|
step forward, and validate state at each step. Fails hard on any anomaly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from harness import Harness, HarnessError
|
||||||
|
|
||||||
|
|
||||||
|
def sha256(path: Path) -> str:
|
||||||
|
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
with Harness.launch(run_name="smoke") as h:
|
||||||
|
print(f"\n[smoke] run dir: {h.root}\n")
|
||||||
|
|
||||||
|
# --- 1. initial state ---
|
||||||
|
print("[smoke] load_mission")
|
||||||
|
state = h.load_mission("campaign_01", 0)
|
||||||
|
assert state["width"] > 0 and state["height"] > 0, state
|
||||||
|
assert state["phase"] == "Paused", f"expected Paused, got {state['phase']}"
|
||||||
|
assert state["remainingStock"], "stock is empty"
|
||||||
|
print(f" board {state['width']}x{state['height']}, phase={state['phase']}, stock={state['remainingStock']}")
|
||||||
|
|
||||||
|
shot1 = h.screenshot("01_loaded")
|
||||||
|
assert shot1.exists(), shot1
|
||||||
|
print(f" screenshot -> {shot1.name}")
|
||||||
|
|
||||||
|
# --- 2. place a piece ---
|
||||||
|
snap_before = h.state()
|
||||||
|
stock_before = dict(snap_before["remainingStock"])
|
||||||
|
first_kind = next(iter(stock_before))
|
||||||
|
print(f"[smoke] try placing {first_kind} at (0,0)->(0,0)")
|
||||||
|
|
||||||
|
# Find any legal placement. Simplest: for a Rook-ish piece, same cell start=end
|
||||||
|
# isn't legal — try adjacent cells. We scan for one kind we have in stock and a
|
||||||
|
# simple legal move.
|
||||||
|
placed = None
|
||||||
|
for kind in stock_before:
|
||||||
|
for s in [(0, 0), (1, 0), (0, 1)]:
|
||||||
|
for e in [(0, 1), (1, 1), (2, 0), (0, 2)]:
|
||||||
|
if s == e:
|
||||||
|
continue
|
||||||
|
result = h.place(kind, s, e)
|
||||||
|
if result.get("placed"):
|
||||||
|
placed = (kind, s, e, result)
|
||||||
|
break
|
||||||
|
if placed:
|
||||||
|
break
|
||||||
|
if placed:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not placed:
|
||||||
|
print("[smoke] no legal placement found — inspect state dump:")
|
||||||
|
print(json.dumps(snap_before, indent=2))
|
||||||
|
return 2
|
||||||
|
|
||||||
|
kind, start, end, result = placed
|
||||||
|
print(f" placed {kind} {start}->{end}, pieceId={result.get('pieceId')}")
|
||||||
|
h.screenshot("02_placed")
|
||||||
|
|
||||||
|
snap_after = h.state()
|
||||||
|
assert len(snap_after["pieces"]) == len(snap_before["pieces"]) + 1, "piece count didn't grow"
|
||||||
|
assert snap_after["remainingStock"][kind] == stock_before[kind] - 1, "stock didn't decrement"
|
||||||
|
|
||||||
|
# --- 3. determinism check: two screenshots of a paused state must match ---
|
||||||
|
print("[smoke] determinism check")
|
||||||
|
a = h.screenshot("det_a")
|
||||||
|
b = h.screenshot("det_b")
|
||||||
|
if sha256(a) != sha256(b):
|
||||||
|
print(f" WARN: screenshots differ (hover cursor likely) — {sha256(a)[:8]} vs {sha256(b)[:8]}")
|
||||||
|
else:
|
||||||
|
print(" identical OK")
|
||||||
|
|
||||||
|
# --- 4. stepping ---
|
||||||
|
print("[smoke] stepping up to 10 turns")
|
||||||
|
for i in range(10):
|
||||||
|
step_info = h.step()
|
||||||
|
h.screenshot(f"step_{i:02}")
|
||||||
|
phase = step_info.get("phase")
|
||||||
|
print(f" turn={step_info.get('turn')} phase={phase}")
|
||||||
|
if phase == "MissionComplete":
|
||||||
|
print(" mission complete!")
|
||||||
|
break
|
||||||
|
|
||||||
|
# --- 5. negative test ---
|
||||||
|
print("[smoke] negative test: off-board placement")
|
||||||
|
try:
|
||||||
|
bad = h.place(kind, (-1, -1), (0, 0))
|
||||||
|
assert not bad.get("placed"), f"expected placed:false, got {bad}"
|
||||||
|
assert bad.get("reason"), f"expected reason, got {bad}"
|
||||||
|
print(f" rejected with: {bad['reason']}")
|
||||||
|
except HarnessError as e:
|
||||||
|
print(f" harness error (acceptable): {e}")
|
||||||
|
|
||||||
|
# --- 6. responsive after negative ---
|
||||||
|
print("[smoke] final state dump")
|
||||||
|
final = h.state()
|
||||||
|
print(f" phase={final['phase']} turn={final['turn']} pieces={len(final['pieces'])}")
|
||||||
|
|
||||||
|
print("\n[smoke] PASS\n")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Loading…
Add table
Reference in a new issue