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:
parent
2537bfe828
commit
97bca7d7df
7 changed files with 260 additions and 2 deletions
|
|
@ -29,6 +29,7 @@ internal class AutomationFacade
|
||||||
public Action Quit { get; }
|
public Action Quit { get; }
|
||||||
public Action QuickSave { get; }
|
public Action QuickSave { get; }
|
||||||
public Action QuickLoad { get; }
|
public Action QuickLoad { get; }
|
||||||
|
public Action Undo { get; }
|
||||||
|
|
||||||
public AutomationFacade(
|
public AutomationFacade(
|
||||||
Func<GameSim?> sim,
|
Func<GameSim?> sim,
|
||||||
|
|
@ -45,7 +46,8 @@ internal class AutomationFacade
|
||||||
Action<float> setSpeed,
|
Action<float> setSpeed,
|
||||||
Action quit,
|
Action quit,
|
||||||
Action quickSave,
|
Action quickSave,
|
||||||
Action quickLoad)
|
Action quickLoad,
|
||||||
|
Action undo)
|
||||||
{
|
{
|
||||||
Sim = sim;
|
Sim = sim;
|
||||||
Input = input;
|
Input = input;
|
||||||
|
|
@ -62,5 +64,6 @@ internal class AutomationFacade
|
||||||
Quit = quit;
|
Quit = quit;
|
||||||
QuickSave = quickSave;
|
QuickSave = quickSave;
|
||||||
QuickLoad = quickLoad;
|
QuickLoad = quickLoad;
|
||||||
|
Undo = undo;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ internal class CommandDispatcher
|
||||||
"back_to_menu" => BackToMenu(),
|
"back_to_menu" => BackToMenu(),
|
||||||
"quick_save" => QuickSave(),
|
"quick_save" => QuickSave(),
|
||||||
"quick_load" => QuickLoad(),
|
"quick_load" => QuickLoad(),
|
||||||
|
"undo" => Undo(),
|
||||||
"quit" => Quit(),
|
"quit" => Quit(),
|
||||||
_ => throw new InvalidOperationException($"Unknown command: {cmd}"),
|
_ => 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()
|
private JsonNode? QuickLoad()
|
||||||
{
|
{
|
||||||
_facade.QuickLoad();
|
_facade.QuickLoad();
|
||||||
|
|
|
||||||
|
|
@ -109,7 +109,8 @@ public partial class Main : Node2D
|
||||||
setSpeed: HarnessSetSpeed,
|
setSpeed: HarnessSetSpeed,
|
||||||
quit: () => GetTree().Quit(),
|
quit: () => GetTree().Quit(),
|
||||||
quickSave: OnQuickSave,
|
quickSave: OnQuickSave,
|
||||||
quickLoad: OnQuickLoad);
|
quickLoad: OnQuickLoad,
|
||||||
|
undo: OnUndo);
|
||||||
|
|
||||||
_automationHarness = new AutomationHarness(dir, facade);
|
_automationHarness = new AutomationHarness(dir, facade);
|
||||||
AddChild(_automationHarness);
|
AddChild(_automationHarness);
|
||||||
|
|
@ -200,6 +201,11 @@ public partial class Main : Node2D
|
||||||
OnQuickLoad();
|
OnQuickLoad();
|
||||||
GetViewport().SetInputAsHandled();
|
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)
|
private void ApplyRestoredSnapshot(BoardSnapshot snap)
|
||||||
{
|
{
|
||||||
if (_campaignDef == null) return;
|
if (_campaignDef == null) return;
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,9 @@ public class GameSim
|
||||||
{
|
{
|
||||||
private readonly BoardState _state;
|
private readonly BoardState _state;
|
||||||
private readonly Dictionary<int, WorldSave> _saveSlots = new();
|
private readonly Dictionary<int, WorldSave> _saveSlots = new();
|
||||||
|
private readonly LinkedList<WorldSave> _undoStack = new();
|
||||||
private const int DefaultSlot = 0;
|
private const int DefaultSlot = 0;
|
||||||
|
private const int UndoStackLimit = 32;
|
||||||
|
|
||||||
public GameSim(LevelDef level)
|
public GameSim(LevelDef level)
|
||||||
{
|
{
|
||||||
|
|
@ -22,6 +24,8 @@ public class GameSim
|
||||||
|
|
||||||
public IReadOnlyList<IWorldEvent> ProcessCommand(IWorldCommand command)
|
public IReadOnlyList<IWorldEvent> ProcessCommand(IWorldCommand command)
|
||||||
{
|
{
|
||||||
|
WorldSave? undoCheckpoint = IsUndoable(command) ? _state.CaptureSave() : null;
|
||||||
|
|
||||||
var changeList = new List<IWorldEvent>();
|
var changeList = new List<IWorldEvent>();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
@ -31,9 +35,47 @@ public class GameSim
|
||||||
{
|
{
|
||||||
return [ex.RejectionEvent];
|
return [ex.RejectionEvent];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (undoCheckpoint != null && ContainsMutation(changeList))
|
||||||
|
PushUndo(undoCheckpoint);
|
||||||
|
|
||||||
return changeList;
|
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);
|
public BoardSnapshot GetSnapshot() => new(_state);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -57,6 +99,7 @@ public class GameSim
|
||||||
return [];
|
return [];
|
||||||
|
|
||||||
_state.RestoreFromSave(save);
|
_state.RestoreFromSave(save);
|
||||||
|
_undoStack.Clear();
|
||||||
return [new StateRestoredEvent(new BoardSnapshot(_state), slot)];
|
return [new StateRestoredEvent(new BoardSnapshot(_state), slot)];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
120
chessistics-tests/Simulation/UndoTests.cs
Normal file
120
chessistics-tests/Simulation/UndoTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -274,6 +274,9 @@ class Harness:
|
||||||
def quick_load(self) -> dict[str, Any]:
|
def quick_load(self) -> dict[str, Any]:
|
||||||
return self.send("quick_load")
|
return self.send("quick_load")
|
||||||
|
|
||||||
|
def undo(self) -> dict[str, Any]:
|
||||||
|
return self.send("undo")
|
||||||
|
|
||||||
def quit(self) -> dict[str, Any]:
|
def quit(self) -> dict[str, Any]:
|
||||||
return self.send("quit", timeout=5.0)
|
return self.send("quit", timeout=5.0)
|
||||||
|
|
||||||
|
|
|
||||||
44
tools/automation/test_undo.py
Normal file
44
tools/automation/test_undo.py
Normal 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()
|
||||||
Loading…
Add table
Reference in a new issue