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