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.
This commit is contained in:
Samuel Bouchet 2026-04-17 22:14:06 +02:00
parent 2537bfe828
commit 97bca7d7df
7 changed files with 260 additions and 2 deletions

View file

@ -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<GameSim?> sim,
@ -45,7 +46,8 @@ internal class AutomationFacade
Action<float> 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;
}
}

View file

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

View file

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

View file

@ -8,7 +8,9 @@ public class GameSim
{
private readonly BoardState _state;
private readonly Dictionary<int, WorldSave> _saveSlots = new();
private readonly LinkedList<WorldSave> _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<IWorldEvent> ProcessCommand(IWorldCommand command)
{
WorldSave? undoCheckpoint = IsUndoable(command) ? _state.CaptureSave() : null;
var changeList = new List<IWorldEvent>();
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<IWorldEvent> 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;
/// <summary>
/// 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.
/// </summary>
public IReadOnlyList<IWorldEvent> 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);
/// <summary>
@ -57,6 +99,7 @@ public class GameSim
return [];
_state.RestoreFromSave(save);
_undoStack.Clear();
return [new StateRestoredEvent(new BoardSnapshot(_state), slot)];
}

View file

@ -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<StateRestoredEvent>(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);
}
}

View file

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

View file

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