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/
|
||||
.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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
if (_selectedKind == null || _snapshot == null)
|
||||
|
|
|
|||
|
|
@ -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,16 +56,95 @@ public partial class Main : Node2D
|
|||
|
||||
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()
|
||||
{
|
||||
RenderingServer.SetDefaultClearColor(BackgroundColor);
|
||||
|
||||
_automationDir = ParseAutomationArg();
|
||||
|
||||
BuildSceneTree();
|
||||
ConnectSignals();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -149,6 +149,13 @@ public partial class PieceStockPanel : VBoxContainer
|
|||
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
|
||||
{
|
||||
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