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:
Samuel Bouchet 2026-04-16 22:34:56 +02:00
parent 2d1aea0a7a
commit f86b9abecd
14 changed files with 1272 additions and 6 deletions

3
.gitignore vendored
View file

@ -21,3 +21,6 @@ Thumbs.db
# Claude Code # Claude Code
.claude/ .claude/
.idea .idea
# Automation harness run outputs
.automation_runs/

View 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;
}
}

View 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=&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);
}
}

View 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}.");
}
}

View 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);
}
}

View 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;
}
}

View file

@ -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)

View file

@ -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)
{ {

View file

@ -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",

View 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.

Binary file not shown.

272
tools/automation/harness.py Normal file
View 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))

View 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
View 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())