From 97bca7d7dfbae9dfc77732775562407240adca05 Mon Sep 17 00:00:00 2001 From: Samuel Bouchet Date: Fri, 17 Apr 2026 22:14:06 +0200 Subject: [PATCH] Add Undo (Ctrl+Z) backed by the WorldSave checkpoint mechanism GameSim snapshots the state before each undoable command (PlacePiece / RemovePiece / MovePiece) into a bounded LinkedList stack (max 32). Undo() pops the last checkpoint and emits StateRestoredEvent, reusing the presentation rebuild path already wired for QuickLoad. Ctrl+Z in Main triggers the engine method; the harness exposes undo() for tests. QuickLoad clears the stack (fresh timeline). Seven unit tests cover empty stack, place/remove/move undo, reverse-order multiple undos, rejected commands not checkpointing, and post-simulation rewind. --- Scripts/Automation/AutomationFacade.cs | 5 +- Scripts/Automation/CommandDispatcher.cs | 16 +++ Scripts/Main.cs | 31 +++++- chessistics-engine/Simulation/GameSim.cs | 43 ++++++++ chessistics-tests/Simulation/UndoTests.cs | 120 ++++++++++++++++++++++ tools/automation/harness.py | 3 + tools/automation/test_undo.py | 44 ++++++++ 7 files changed, 260 insertions(+), 2 deletions(-) create mode 100644 chessistics-tests/Simulation/UndoTests.cs create mode 100644 tools/automation/test_undo.py diff --git a/Scripts/Automation/AutomationFacade.cs b/Scripts/Automation/AutomationFacade.cs index 7f36e2a..487580c 100644 --- a/Scripts/Automation/AutomationFacade.cs +++ b/Scripts/Automation/AutomationFacade.cs @@ -29,6 +29,7 @@ internal class AutomationFacade public Action Quit { get; } public Action QuickSave { get; } public Action QuickLoad { get; } + public Action Undo { get; } public AutomationFacade( Func sim, @@ -45,7 +46,8 @@ internal class AutomationFacade Action setSpeed, Action quit, Action quickSave, - Action quickLoad) + Action quickLoad, + Action undo) { Sim = sim; Input = input; @@ -62,5 +64,6 @@ internal class AutomationFacade Quit = quit; QuickSave = quickSave; QuickLoad = quickLoad; + Undo = undo; } } diff --git a/Scripts/Automation/CommandDispatcher.cs b/Scripts/Automation/CommandDispatcher.cs index 1fea490..9905fa9 100644 --- a/Scripts/Automation/CommandDispatcher.cs +++ b/Scripts/Automation/CommandDispatcher.cs @@ -43,6 +43,7 @@ internal class CommandDispatcher "back_to_menu" => BackToMenu(), "quick_save" => QuickSave(), "quick_load" => QuickLoad(), + "undo" => Undo(), "quit" => Quit(), _ => throw new InvalidOperationException($"Unknown command: {cmd}"), }; @@ -234,6 +235,21 @@ internal class CommandDispatcher }; } + private JsonNode? Undo() + { + var sim = _facade.Sim(); + var hadUndo = sim?.CanUndo ?? false; + _facade.Undo(); + if (sim == null) return new JsonObject { ["undone"] = false }; + var snap = sim.GetSnapshot(); + return new JsonObject + { + ["undone"] = hadUndo, + ["turn"] = snap.TurnNumber, + ["canUndo"] = sim.CanUndo + }; + } + private JsonNode? QuickLoad() { _facade.QuickLoad(); diff --git a/Scripts/Main.cs b/Scripts/Main.cs index 6ab4b91..788f762 100644 --- a/Scripts/Main.cs +++ b/Scripts/Main.cs @@ -109,7 +109,8 @@ public partial class Main : Node2D setSpeed: HarnessSetSpeed, quit: () => GetTree().Quit(), quickSave: OnQuickSave, - quickLoad: OnQuickLoad); + quickLoad: OnQuickLoad, + undo: OnUndo); _automationHarness = new AutomationHarness(dir, facade); AddChild(_automationHarness); @@ -200,6 +201,11 @@ public partial class Main : Node2D OnQuickLoad(); GetViewport().SetInputAsHandled(); } + else if (key.Keycode == Key.Z && key.CtrlPressed) + { + OnUndo(); + GetViewport().SetInputAsHandled(); + } } } @@ -817,6 +823,29 @@ public partial class Main : Node2D } } + private void OnUndo() + { + if (_sim == null) return; + if (!_sim.CanUndo) + { + GD.Print("[Undo] Nothing to undo"); + return; + } + + _running = false; + _simTimer.Stop(); + + var events = _sim.Undo(); + foreach (var evt in events) + { + if (evt is StateRestoredEvent restored) + { + GD.Print($"[Undo] Reverted to turn {restored.Snapshot.TurnNumber}"); + ApplyRestoredSnapshot(restored.Snapshot); + } + } + } + private void ApplyRestoredSnapshot(BoardSnapshot snap) { if (_campaignDef == null) return; diff --git a/chessistics-engine/Simulation/GameSim.cs b/chessistics-engine/Simulation/GameSim.cs index 13b48cb..f01485d 100644 --- a/chessistics-engine/Simulation/GameSim.cs +++ b/chessistics-engine/Simulation/GameSim.cs @@ -8,7 +8,9 @@ public class GameSim { private readonly BoardState _state; private readonly Dictionary _saveSlots = new(); + private readonly LinkedList _undoStack = new(); private const int DefaultSlot = 0; + private const int UndoStackLimit = 32; public GameSim(LevelDef level) { @@ -22,6 +24,8 @@ public class GameSim public IReadOnlyList ProcessCommand(IWorldCommand command) { + WorldSave? undoCheckpoint = IsUndoable(command) ? _state.CaptureSave() : null; + var changeList = new List(); try { @@ -31,9 +35,47 @@ public class GameSim { return [ex.RejectionEvent]; } + + if (undoCheckpoint != null && ContainsMutation(changeList)) + PushUndo(undoCheckpoint); + return changeList; } + private static bool IsUndoable(IWorldCommand command) => command switch + { + PlacePieceCommand => true, + RemovePieceCommand => true, + MovePieceCommand => true, + _ => false + }; + + private static bool ContainsMutation(List events) => + events.Any(e => e is PiecePlacedEvent or PieceRemovedEvent or PieceMovedByPlayerEvent); + + private void PushUndo(WorldSave save) + { + _undoStack.AddLast(save); + while (_undoStack.Count > UndoStackLimit) + _undoStack.RemoveFirst(); + } + + public bool CanUndo => _undoStack.Count > 0; + + /// + /// Revert the last undoable mutation (placement, removal, or move) by + /// restoring the pre-command snapshot. Emits StateRestoredEvent so the + /// presentation can rebuild visuals. Returns empty if nothing to undo. + /// + public IReadOnlyList Undo() + { + if (_undoStack.Count == 0) return []; + var save = _undoStack.Last!.Value; + _undoStack.RemoveLast(); + _state.RestoreFromSave(save); + return [new StateRestoredEvent(new BoardSnapshot(_state), null)]; + } + public BoardSnapshot GetSnapshot() => new(_state); /// @@ -57,6 +99,7 @@ public class GameSim return []; _state.RestoreFromSave(save); + _undoStack.Clear(); return [new StateRestoredEvent(new BoardSnapshot(_state), slot)]; } diff --git a/chessistics-tests/Simulation/UndoTests.cs b/chessistics-tests/Simulation/UndoTests.cs new file mode 100644 index 0000000..10d923d --- /dev/null +++ b/chessistics-tests/Simulation/UndoTests.cs @@ -0,0 +1,120 @@ +using Chessistics.Engine.Commands; +using Chessistics.Engine.Events; +using Chessistics.Engine.Model; +using Chessistics.Tests.Helpers; +using Xunit; + +namespace Chessistics.Tests.Simulation; + +public class UndoTests +{ + private SimHelper CreateSim() + { + var level = new BoardBuilder(4, 4) + .WithProduction(0, 0, "Scierie", CargoType.Wood, amount: 1) + .WithDemand(3, 0, "Depot", CargoType.Wood, 3, 30) + .WithStock(PieceKind.Rook, 3) + .Build(); + return SimHelper.FromLevel(level); + } + + [Fact] + public void Undo_EmptyStack_ReturnsNoEvents() + { + var sim = CreateSim(); + Assert.False(sim.Sim.CanUndo); + Assert.Empty(sim.Sim.Undo()); + } + + [Fact] + public void Undo_AfterPlace_RestoresPreviousState() + { + var sim = CreateSim(); + Assert.False(sim.Sim.CanUndo); + + sim.Place(PieceKind.Rook, (0, 0), (1, 0)); + Assert.Single(sim.Snapshot.Pieces); + Assert.True(sim.Sim.CanUndo); + + var events = sim.Sim.Undo(); + Assert.Single(events); + Assert.IsType(events[0]); + Assert.Empty(sim.Snapshot.Pieces); + Assert.Equal(3, sim.Snapshot.RemainingStock[PieceKind.Rook]); + Assert.False(sim.Sim.CanUndo); + } + + [Fact] + public void Undo_AfterRemove_RestoresPiece() + { + var sim = CreateSim(); + sim.Place(PieceKind.Rook, (0, 0), (1, 0)); + var pieceId = sim.Snapshot.Pieces[0].Id; + + sim.Remove(pieceId); + Assert.Empty(sim.Snapshot.Pieces); + + sim.Sim.Undo(); + Assert.Single(sim.Snapshot.Pieces); + Assert.Equal(pieceId, sim.Snapshot.Pieces[0].Id); + } + + [Fact] + public void Undo_MultipleMutations_UndoneInReverseOrder() + { + var sim = CreateSim(); + sim.Place(PieceKind.Rook, (0, 0), (1, 0)); + sim.Place(PieceKind.Rook, (0, 1), (1, 1)); + sim.Place(PieceKind.Rook, (0, 2), (1, 2)); + Assert.Equal(3, sim.Snapshot.Pieces.Count); + + sim.Sim.Undo(); + Assert.Equal(2, sim.Snapshot.Pieces.Count); + + sim.Sim.Undo(); + Assert.Single(sim.Snapshot.Pieces); + + sim.Sim.Undo(); + Assert.Empty(sim.Snapshot.Pieces); + + Assert.False(sim.Sim.CanUndo); + } + + [Fact] + public void Undo_RejectedPlacement_DoesNotCheckpoint() + { + var sim = CreateSim(); + // Invalid placement (off the board) — should be rejected and not undoable + sim.Place(PieceKind.Rook, (99, 99), (100, 100)); + Assert.False(sim.Sim.CanUndo); + } + + [Fact] + public void Undo_AfterSimulationStep_RewindsTurnsToo() + { + // Placing then stepping means the sim advanced. Undo of the placement + // should restore the pre-placement state — which also means turn 0. + var sim = CreateSim(); + sim.Place(PieceKind.Rook, (0, 0), (1, 0)); + sim.Step(); + sim.Step(); + Assert.Equal(2, sim.Snapshot.TurnNumber); + + sim.Sim.Undo(); + Assert.Equal(0, sim.Snapshot.TurnNumber); + Assert.Empty(sim.Snapshot.Pieces); + } + + [Fact] + public void QuickLoad_ClearsUndoStack() + { + var sim = CreateSim(); + sim.Place(PieceKind.Rook, (0, 0), (1, 0)); + sim.Sim.QuickSave(); + sim.Place(PieceKind.Rook, (0, 1), (1, 1)); + + // Two undoable mutations so far — load clears the stack + sim.Sim.QuickLoad(); + Assert.False(sim.Sim.CanUndo); + } +} diff --git a/tools/automation/harness.py b/tools/automation/harness.py index 2d38664..42a910d 100644 --- a/tools/automation/harness.py +++ b/tools/automation/harness.py @@ -274,6 +274,9 @@ class Harness: def quick_load(self) -> dict[str, Any]: return self.send("quick_load") + def undo(self) -> dict[str, Any]: + return self.send("undo") + def quit(self) -> dict[str, Any]: return self.send("quit", timeout=5.0) diff --git a/tools/automation/test_undo.py b/tools/automation/test_undo.py new file mode 100644 index 0000000..3d62435 --- /dev/null +++ b/tools/automation/test_undo.py @@ -0,0 +1,44 @@ +"""End-to-end smoke test for Undo.""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from tools.automation.harness import Harness + + +def main(): + with Harness.launch(run_name="undo") as h: + h.load_mission("campaign_01", 0) + h.screenshot("01_loaded") + initial = h.state() + assert len(initial['pieces']) == 0 + + h.place("Pawn", (0, 0), (0, 1)) + h.place("Pawn", (1, 0), (1, 1)) + h.screenshot("02_two_pieces") + s = h.state() + assert len(s['pieces']) == 2, f"expected 2 pieces, got {len(s['pieces'])}" + + r = h.undo() + print(f"[undo] {r}") + h.screenshot("03_after_first_undo") + s = h.state() + assert len(s['pieces']) == 1, f"expected 1 piece, got {len(s['pieces'])}" + + r = h.undo() + print(f"[undo] {r}") + h.screenshot("04_after_second_undo") + s = h.state() + assert len(s['pieces']) == 0 + assert s['remainingStock']['Pawn'] == 4 + + r = h.undo() + print(f"[undo empty] {r}") + assert r['undone'] is False + + print("OK — Undo works") + + +if __name__ == "__main__": + main()