BoardState.CaptureSave/RestoreFromSave deep-copy every mutable field (grid, pieces, demands, transformers, buffers, stock, campaign progress) into a WorldSave slot. GameSim.QuickSave/QuickLoad expose slotted saves and emit StateSavedEvent / StateRestoredEvent — the latter carries a fresh BoardSnapshot so the presentation can rebuild board, pieces, trajectories, objectives, stock, camera, and control bar in one pass. F5/F9 trigger it in Main; harness gains quick_save/quick_load commands so UI tests can checkpoint a scenario and resume without replaying from scratch. Seven xUnit tests cover the roundtrip (including independence from post-save mutations, campaign state, and multi-slot isolation).
294 lines
9.2 KiB
C#
294 lines
9.2 KiB
C#
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(),
|
|
"quick_save" => QuickSave(),
|
|
"quick_load" => QuickLoad(),
|
|
"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? QuickSave()
|
|
{
|
|
_facade.QuickSave();
|
|
var sim = _facade.Sim();
|
|
return new JsonObject
|
|
{
|
|
["saved"] = sim != null,
|
|
["turn"] = sim?.GetSnapshot().TurnNumber
|
|
};
|
|
}
|
|
|
|
private JsonNode? QuickLoad()
|
|
{
|
|
_facade.QuickLoad();
|
|
var sim = _facade.Sim();
|
|
if (sim == null) return new JsonObject { ["loaded"] = false };
|
|
var snap = sim.GetSnapshot();
|
|
return new JsonObject
|
|
{
|
|
["loaded"] = true,
|
|
["turn"] = snap.TurnNumber,
|
|
["phase"] = snap.Phase.ToString(),
|
|
["missionIndex"] = snap.Campaign?.CurrentMissionIndex
|
|
};
|
|
}
|
|
|
|
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}.");
|
|
}
|
|
}
|