diff --git a/.gitignore b/.gitignore index dc3cd8c..e19dafe 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ Thumbs.db # Claude Code .claude/ .idea + +# Automation harness run outputs +.automation_runs/ diff --git a/Scripts/Automation/AutomationFacade.cs b/Scripts/Automation/AutomationFacade.cs new file mode 100644 index 0000000..45c0848 --- /dev/null +++ b/Scripts/Automation/AutomationFacade.cs @@ -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; + +/// +/// 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. +/// +internal class AutomationFacade +{ + public Func Sim { get; } + public InputMapper Input { get; } + public EventAnimator Animator { get; } + public PieceStockPanel Stock { get; } + public ControlBar ControlBar { get; } + + public Action LoadMission { get; } + public Action Play { get; } + public Action Pause { get; } + public Action Step { get; } + public Action TogglePlayPause { get; } + public Action BackToMenu { get; } + public Action SetSpeed { get; } + public Action Quit { get; } + + public AutomationFacade( + Func sim, + InputMapper input, + EventAnimator animator, + PieceStockPanel stock, + ControlBar controlBar, + Action loadMission, + Action play, + Action pause, + Action step, + Action togglePlayPause, + Action backToMenu, + Action 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; + } +} diff --git a/Scripts/Automation/AutomationHarness.cs b/Scripts/Automation/AutomationHarness.cs new file mode 100644 index 0000000..c03efbf --- /dev/null +++ b/Scripts/Automation/AutomationHarness.cs @@ -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; + +/// +/// 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/. +/// +public partial class AutomationHarness : Node +{ + public string Root { get; } + private readonly AutomationFacade _facade; + private CommandDispatcher _dispatcher = null!; + private bool _busy; + private readonly HashSet _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() ?? 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(); + 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; + } + + /// Awaiting a signal from C#; wrapper so dispatcher can use it. + internal SignalAwaiter ToSignalAsync(GodotObject source, string signal) => ToSignal(source, signal); + + /// Called via CallDeferred by the quit command. + public void RequestQuit() => GetTree().Quit(); + + private static async Task 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); + } +} diff --git a/Scripts/Automation/CommandDispatcher.cs b/Scripts/Automation/CommandDispatcher.cs new file mode 100644 index 0000000..a03af50 --- /dev/null +++ b/Scripts/Automation/CommandDispatcher.cs @@ -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; + +/// +/// Maps command strings → handlers. Each handler returns the "result" payload; +/// the harness wraps it in the envelope. +/// +internal class CommandDispatcher +{ + private readonly AutomationHarness _harness; + private readonly AutomationFacade _facade; + + public CommandDispatcher(AutomationHarness harness, AutomationFacade facade) + { + _harness = harness; + _facade = facade; + } + + public async Task 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 Screenshot(JsonObject args) + { + var name = args["name"]?.GetValue() ?? "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(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(); + var row = args["row"]!.GetValue(); + var button = args["button"]?.GetValue()?.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(); + 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 Step() + { + _facade.Step(); + // Dispatcher auto-waits for idle before writing the result. + await WaitIdleInternal(timeoutMs: 10000); + return PhaseInfo(); + } + + private async Task WaitIdle(JsonObject args) + { + var timeout = args["timeoutMs"]?.GetValue() ?? 10000; + var reached = await WaitIdleInternal(timeout); + var info = PhaseInfo()!.AsObject(); + info["idle"] = reached; + return info; + } + + internal async Task 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(); + _facade.SetSpeed(interval); + return new JsonObject { ["interval"] = interval }; + } + + private JsonNode? LoadMission(JsonObject args) + { + var campaign = args["campaign"]?.GetValue() ?? "campaign_01"; + var index = args["missionIndex"]?.GetValue() ?? 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(out var i)) return (PieceKind)i; + var s = val.GetValue(); + if (Enum.TryParse(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(), arr[1]!.GetValue()); + if (node is JsonObject obj && obj.ContainsKey("col") && obj.ContainsKey("row")) + return new Coords(obj["col"]!.GetValue(), obj["row"]!.GetValue()); + throw new InvalidOperationException("Coords must be [col,row] or {col,row}."); + } +} diff --git a/Scripts/Automation/IpcFiles.cs b/Scripts/Automation/IpcFiles.cs new file mode 100644 index 0000000..619c7e6 --- /dev/null +++ b/Scripts/Automation/IpcFiles.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace Chessistics.Scripts.Automation; + +/// +/// Tiny helpers for the file-based IPC. All writes are atomic: write .tmp then rename. +/// +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); + } + + /// + /// 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. + /// + public static string? NextInbox(string root, HashSet 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); + } +} diff --git a/Scripts/Automation/SnapshotSerializer.cs b/Scripts/Automation/SnapshotSerializer.cs new file mode 100644 index 0000000..7732381 --- /dev/null +++ b/Scripts/Automation/SnapshotSerializer.cs @@ -0,0 +1,123 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text.Json.Nodes; +using Chessistics.Engine.Model; + +namespace Chessistics.Scripts.Automation; + +/// +/// Serializes BoardSnapshot into plain JSON so the agent can reason about game state. +/// +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()), + ["demands"] = new JsonArray(snap.Demands.Select(SerializeDemand).ToArray()), + ["transformers"] = new JsonArray(snap.Transformers.Select(SerializeTransformer).ToArray()), + ["pieces"] = new JsonArray(snap.Pieces.Select(SerializePiece).ToArray()), + ["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 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; + } +} diff --git a/Scripts/Input/InputMapper.cs b/Scripts/Input/InputMapper.cs index d4ec8b3..4dd28d3 100644 --- a/Scripts/Input/InputMapper.cs +++ b/Scripts/Input/InputMapper.cs @@ -101,22 +101,41 @@ public partial class InputMapper : Node return; } + HandleClickAt(coords.Value); + } + + private void HandleClickAt(Coords coords) + { switch (_phase) { case PlacementPhase.SelectingStart: - OnStartSelected(coords.Value); + OnStartSelected(coords); break; case PlacementPhase.SelectingEnd: - OnEndSelected(coords.Value); + OnEndSelected(coords); break; default: - EmitSignal(SignalName.CellClicked, coords.Value.Col, coords.Value.Row); + EmitSignal(SignalName.CellClicked, coords.Col, coords.Row); break; } } + /// + /// Same effect as a left/right click on a board cell, for automation. + /// Runs the exact branch HandleLeftClick runs (no InputEvent synthesis). + /// + public void SimulateClick(Coords coords, MouseButton button) + { + if (button == MouseButton.Right) + { + Cancel(); + return; + } + HandleClickAt(coords); + } + private void OnStartSelected(Coords start) { if (_selectedKind == null || _snapshot == null) diff --git a/Scripts/Main.cs b/Scripts/Main.cs index 6e1650c..0aae93f 100644 --- a/Scripts/Main.cs +++ b/Scripts/Main.cs @@ -7,6 +7,7 @@ using Chessistics.Engine.Events; using Chessistics.Engine.Loading; using Chessistics.Engine.Model; using Chessistics.Engine.Simulation; +using Chessistics.Scripts.Automation; using Chessistics.Scripts.Board; using Chessistics.Scripts.Input; using Chessistics.Scripts.Pieces; @@ -55,15 +56,94 @@ public partial class Main : Node2D private static readonly Color BackgroundColor = new("#2D2D2D"); + // Automation harness (active only when --automation= CLI flag is given) + private string? _automationDir; + private AutomationHarness? _automationHarness; + public override void _Ready() { RenderingServer.SetDefaultClearColor(BackgroundColor); + _automationDir = ParseAutomationArg(); + BuildSceneTree(); ConnectSignals(); ShowTitleScreen(); - FadeIn(0.5f); + 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); + } + } + + 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) @@ -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); if (file == null) { diff --git a/Scripts/UI/PieceStockPanel.cs b/Scripts/UI/PieceStockPanel.cs index 72c024b..4d579a8 100644 --- a/Scripts/UI/PieceStockPanel.cs +++ b/Scripts/UI/PieceStockPanel.cs @@ -149,6 +149,13 @@ public partial class PieceStockPanel : VBoxContainer UpdateButtonStates(); } + /// Automation hook — runs the same path as clicking a piece button. + public void SimulateSelect(PieceKind kind) + { + if (!_entries.ContainsKey(kind)) return; + OnPieceButtonPressed(kind); + } + private static string GetPieceName(PieceKind kind) => kind switch { PieceKind.Pawn => "Pion", diff --git a/tools/automation/README.md b/tools/automation/README.md new file mode 100644 index 0000000..8b76a2b --- /dev/null +++ b/tools/automation/README.md @@ -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=`. The game activates an + `AutomationHarness` node that polls `/inbox/` each frame. +- Send commands as JSON files; the game writes results to `/outbox/` + and screenshots to `/screens/`. +- A handshake file `/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//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": "", "cmd": "...", "args": {...} }` +Outbox: `{ "id": "", "ok": true, "result": {...} }` or + `{ "id": "", "ok": false, "error": "..." }` + +See the `cmd` table in `Scripts/Automation/CommandDispatcher.cs` for the +complete list. + +## Output locations + +- `.automation_runs//ready.json` — handshake +- `.automation_runs//inbox/`, `outbox/` — command queue +- `.automation_runs//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. diff --git a/tools/automation/__pycache__/harness.cpython-313.pyc b/tools/automation/__pycache__/harness.cpython-313.pyc new file mode 100644 index 0000000..5ae3e9d Binary files /dev/null and b/tools/automation/__pycache__/harness.cpython-313.pyc differ diff --git a/tools/automation/harness.py b/tools/automation/harness.py new file mode 100644 index 0000000..59edd4e --- /dev/null +++ b/tools/automation/harness.py @@ -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 `/ready.json` when the automation node is live, + reads commands from `/inbox/.json`, and writes results to + `/outbox/.json`. Screenshots land in `/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)) diff --git a/tools/automation/run_game.py b/tools/automation/run_game.py new file mode 100644 index 0000000..162c94e --- /dev/null +++ b/tools/automation/run_game.py @@ -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. 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) diff --git a/tools/automation/smoke.py b/tools/automation/smoke.py new file mode 100644 index 0000000..1cdf4d5 --- /dev/null +++ b/tools/automation/smoke.py @@ -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())