diff --git a/Scripts/Automation/AutomationFacade.cs b/Scripts/Automation/AutomationFacade.cs index 45c0848..7f36e2a 100644 --- a/Scripts/Automation/AutomationFacade.cs +++ b/Scripts/Automation/AutomationFacade.cs @@ -27,6 +27,8 @@ internal class AutomationFacade public Action BackToMenu { get; } public Action SetSpeed { get; } public Action Quit { get; } + public Action QuickSave { get; } + public Action QuickLoad { get; } public AutomationFacade( Func sim, @@ -41,7 +43,9 @@ internal class AutomationFacade Action togglePlayPause, Action backToMenu, Action setSpeed, - Action quit) + Action quit, + Action quickSave, + Action quickLoad) { Sim = sim; Input = input; @@ -56,5 +60,7 @@ internal class AutomationFacade BackToMenu = backToMenu; SetSpeed = setSpeed; Quit = quit; + QuickSave = quickSave; + QuickLoad = quickLoad; } } diff --git a/Scripts/Automation/CommandDispatcher.cs b/Scripts/Automation/CommandDispatcher.cs index a03af50..1fea490 100644 --- a/Scripts/Automation/CommandDispatcher.cs +++ b/Scripts/Automation/CommandDispatcher.cs @@ -41,6 +41,8 @@ internal class CommandDispatcher "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}"), }; @@ -221,6 +223,32 @@ internal class CommandDispatcher 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. diff --git a/Scripts/Main.cs b/Scripts/Main.cs index 0aae93f..6ab4b91 100644 --- a/Scripts/Main.cs +++ b/Scripts/Main.cs @@ -107,7 +107,9 @@ public partial class Main : Node2D togglePlayPause: TogglePlayPause, backToMenu: HarnessBackToMenu, setSpeed: HarnessSetSpeed, - quit: () => GetTree().Quit()); + quit: () => GetTree().Quit(), + quickSave: OnQuickSave, + quickLoad: OnQuickLoad); _automationHarness = new AutomationHarness(dir, facade); AddChild(_automationHarness); @@ -181,10 +183,23 @@ public partial class Main : Node2D _rightDragged = true; _camera.Position -= motion.Relative / _camera.Zoom; } - else if (@event is InputEventKey key && key.Pressed && !key.Echo && key.Keycode == Key.Space) + else if (@event is InputEventKey key && key.Pressed && !key.Echo) { - TogglePlayPause(); - GetViewport().SetInputAsHandled(); + if (key.Keycode == Key.Space) + { + TogglePlayPause(); + GetViewport().SetInputAsHandled(); + } + else if (key.Keycode == Key.F5) + { + OnQuickSave(); + GetViewport().SetInputAsHandled(); + } + else if (key.Keycode == Key.F9) + { + OnQuickLoad(); + GetViewport().SetInputAsHandled(); + } } } @@ -767,6 +782,56 @@ public partial class Main : Node2D ); } + private void OnQuickSave() + { + if (_sim == null) return; + var events = _sim.QuickSave(); + foreach (var evt in events) + { + if (evt is StateSavedEvent saved) + GD.Print($"[QuickSave] Slot {saved.SlotId} — turn {saved.TurnNumber}"); + } + } + + private void OnQuickLoad() + { + if (_sim == null || _campaignDef == null) return; + if (!_sim.HasSave()) + { + GD.Print("[QuickLoad] No save available"); + return; + } + + // Halt any running simulation loop before rebuilding visuals + _running = false; + _simTimer.Stop(); + + var events = _sim.QuickLoad(); + foreach (var evt in events) + { + if (evt is StateRestoredEvent restored) + { + GD.Print($"[QuickLoad] Slot {restored.SlotId} — turn {restored.Snapshot.TurnNumber}"); + ApplyRestoredSnapshot(restored.Snapshot); + } + } + } + + private void ApplyRestoredSnapshot(BoardSnapshot snap) + { + if (_campaignDef == null) return; + + var mission = _campaignDef.Missions[snap.Campaign!.CurrentMissionIndex]; + + BuildBoardFromSnapshot(snap); + SetupUIForMission(snap, mission); + + CenterCameraOnBoard(snap.Width, snap.Height); + _inputMapper.SetSnapshot(snap); + _controlBar.UpdateTurn(snap.TurnNumber); + _controlBar.UpdateForPhase(snap.Phase); + } + private void OnMissionAdvanced() { // Auto-advance happened during simulation — rebuild board seamlessly diff --git a/chessistics-engine/Events/WorldEvents.cs b/chessistics-engine/Events/WorldEvents.cs index 4ddb55f..fe1256d 100644 --- a/chessistics-engine/Events/WorldEvents.cs +++ b/chessistics-engine/Events/WorldEvents.cs @@ -33,3 +33,7 @@ public record TurnEndedEvent(int TurnNumber) : IWorldEvent; // Drag & drop public record PieceMovedByPlayerEvent(int PieceId, Coords OldStart, Coords OldEnd, Coords NewStart, Coords NewEnd) : IWorldEvent; + +// QuickSave/QuickLoad — presentation must rebuild all visuals from Snapshot on Restored. +public record StateSavedEvent(int TurnNumber, int? SlotId) : IWorldEvent; +public record StateRestoredEvent(BoardSnapshot Snapshot, int? SlotId) : IWorldEvent; diff --git a/chessistics-engine/Model/BoardState.cs b/chessistics-engine/Model/BoardState.cs index 95c8fc7..c8454b0 100644 --- a/chessistics-engine/Model/BoardState.cs +++ b/chessistics-engine/Model/BoardState.cs @@ -222,6 +222,100 @@ public class BoardState } } + /// + /// Capture a deep copy of every mutable field (for QuickSave). + /// Immutable defs and CampaignDef are shared by reference. + /// + public WorldSave CaptureSave() + { + var grid = new CellType[Width, Height]; + Array.Copy(Grid, grid, Grid.Length); + + return new WorldSave + { + Width = Width, + Height = Height, + Grid = grid, + Phase = Phase, + TurnNumber = TurnNumber, + NextPieceId = NextPieceId, + Productions = new Dictionary(Productions), + ProductionBuffers = new Dictionary(ProductionBuffers), + Demands = Demands.ToDictionary(kv => kv.Key, kv => kv.Value.Clone()), + Transformers = new Dictionary(Transformers), + TransformerInputBuffers = new Dictionary(TransformerInputBuffers), + TransformerOutputBuffers = new Dictionary(TransformerOutputBuffers), + Pieces = Pieces.Select(p => p.Clone()).ToList(), + DestroyedPieces = DestroyedPieces.Select(p => p.Clone()).ToList(), + RemainingStock = new Dictionary(RemainingStock), + OccupiedCells = new HashSet(OccupiedCells), + Campaign = Campaign == null ? null : new CampaignSaveData + { + CurrentMissionIndex = Campaign.CurrentMissionIndex, + CompletedMissions = new List(Campaign.CompletedMissions), + AvailablePieceKinds = new HashSet(Campaign.AvailablePieceKinds), + AvailableLevels = new HashSet(Campaign.AvailableLevels) + } + }; + } + + /// + /// Restore every mutable field from a save. Board dimensions can grow + /// or shrink; the grid is fully replaced. + /// + public void RestoreFromSave(WorldSave save) + { + Width = save.Width; + Height = save.Height; + Grid = new CellType[Width, Height]; + Array.Copy(save.Grid, Grid, save.Grid.Length); + + Phase = save.Phase; + TurnNumber = save.TurnNumber; + NextPieceId = save.NextPieceId; + + Productions.Clear(); + foreach (var kv in save.Productions) Productions[kv.Key] = kv.Value; + + ProductionBuffers.Clear(); + foreach (var kv in save.ProductionBuffers) ProductionBuffers[kv.Key] = kv.Value; + + Demands.Clear(); + foreach (var kv in save.Demands) Demands[kv.Key] = kv.Value.Clone(); + + Transformers.Clear(); + foreach (var kv in save.Transformers) Transformers[kv.Key] = kv.Value; + + TransformerInputBuffers.Clear(); + foreach (var kv in save.TransformerInputBuffers) TransformerInputBuffers[kv.Key] = kv.Value; + + TransformerOutputBuffers.Clear(); + foreach (var kv in save.TransformerOutputBuffers) TransformerOutputBuffers[kv.Key] = kv.Value; + + Pieces.Clear(); + foreach (var p in save.Pieces) Pieces.Add(p.Clone()); + + DestroyedPieces.Clear(); + foreach (var p in save.DestroyedPieces) DestroyedPieces.Add(p.Clone()); + + RemainingStock.Clear(); + foreach (var kv in save.RemainingStock) RemainingStock[kv.Key] = kv.Value; + + OccupiedCells.Clear(); + foreach (var c in save.OccupiedCells) OccupiedCells.Add(c); + + if (save.Campaign != null && Campaign != null) + { + Campaign.CurrentMissionIndex = save.Campaign.CurrentMissionIndex; + Campaign.CompletedMissions.Clear(); + Campaign.CompletedMissions.AddRange(save.Campaign.CompletedMissions); + Campaign.AvailablePieceKinds.Clear(); + foreach (var k in save.Campaign.AvailablePieceKinds) Campaign.AvailablePieceKinds.Add(k); + Campaign.AvailableLevels.Clear(); + foreach (var u in save.Campaign.AvailableLevels) Campaign.AvailableLevels.Add(u); + } + } + private void ClearBuildingAt(Coords coords) { Productions.Remove(coords); diff --git a/chessistics-engine/Model/DemandState.cs b/chessistics-engine/Model/DemandState.cs index b7f663f..d1378d4 100644 --- a/chessistics-engine/Model/DemandState.cs +++ b/chessistics-engine/Model/DemandState.cs @@ -19,4 +19,9 @@ public class DemandState public CargoType Cargo => Definition.Cargo; public int Required => Definition.Amount; public int Deadline => Definition.Deadline; + + public DemandState Clone() + { + return new DemandState(Definition, MissionIndex) { ReceivedCount = ReceivedCount }; + } } diff --git a/chessistics-engine/Model/PieceState.cs b/chessistics-engine/Model/PieceState.cs index d15381b..28c8b83 100644 --- a/chessistics-engine/Model/PieceState.cs +++ b/chessistics-engine/Model/PieceState.cs @@ -40,4 +40,15 @@ public class PieceState EndCell = newEnd; CurrentCell = newStart; } + + public PieceState Clone() + { + var clone = new PieceState(Id, Kind, StartCell, EndCell, PlacementOrder, Level) + { + CurrentCell = CurrentCell, + Cargo = Cargo, + CargoFilter = CargoFilter + }; + return clone; + } } diff --git a/chessistics-engine/Model/WorldSave.cs b/chessistics-engine/Model/WorldSave.cs new file mode 100644 index 0000000..2a34aff --- /dev/null +++ b/chessistics-engine/Model/WorldSave.cs @@ -0,0 +1,40 @@ +namespace Chessistics.Engine.Model; + +/// +/// Deep copy of every mutable field needed to restore a BoardState to an +/// earlier point. Used by QuickSave/QuickLoad for rapid iteration during +/// UI/UX testing and by the harness to restore checkpoints. +/// +/// Immutable refs (defs, CampaignDef) are shared; mutable state (Pieces, +/// DemandState, buffers, stock, campaign progression) is copied by value. +/// +public sealed class WorldSave +{ + public int Width { get; init; } + public int Height { get; init; } + public CellType[,] Grid { get; init; } = new CellType[0, 0]; + public SimPhase Phase { get; init; } + public int TurnNumber { get; init; } + public int NextPieceId { get; init; } + + public Dictionary Productions { get; init; } = new(); + public Dictionary ProductionBuffers { get; init; } = new(); + public Dictionary Demands { get; init; } = new(); + public Dictionary Transformers { get; init; } = new(); + public Dictionary TransformerInputBuffers { get; init; } = new(); + public Dictionary TransformerOutputBuffers { get; init; } = new(); + public List Pieces { get; init; } = new(); + public List DestroyedPieces { get; init; } = new(); + public Dictionary RemainingStock { get; init; } = new(); + public HashSet OccupiedCells { get; init; } = new(); + + public CampaignSaveData? Campaign { get; init; } +} + +public sealed class CampaignSaveData +{ + public int CurrentMissionIndex { get; init; } + public List CompletedMissions { get; init; } = new(); + public HashSet AvailablePieceKinds { get; init; } = new(); + public HashSet AvailableLevels { get; init; } = new(); +} diff --git a/chessistics-engine/Simulation/GameSim.cs b/chessistics-engine/Simulation/GameSim.cs index b0c8374..13b48cb 100644 --- a/chessistics-engine/Simulation/GameSim.cs +++ b/chessistics-engine/Simulation/GameSim.cs @@ -7,6 +7,8 @@ namespace Chessistics.Engine.Simulation; public class GameSim { private readonly BoardState _state; + private readonly Dictionary _saveSlots = new(); + private const int DefaultSlot = 0; public GameSim(LevelDef level) { @@ -33,4 +35,30 @@ public class GameSim } public BoardSnapshot GetSnapshot() => new(_state); + + /// + /// Capture a full deep-copy of the world into an in-memory slot. + /// Returns a single StateSavedEvent — no state mutation. + /// + public IReadOnlyList QuickSave(int slot = DefaultSlot) + { + _saveSlots[slot] = _state.CaptureSave(); + return [new StateSavedEvent(_state.TurnNumber, slot)]; + } + + /// + /// Restore the world from a saved slot. Emits StateRestoredEvent with a + /// fresh snapshot — the presentation layer must rebuild all visuals. + /// Returns an empty list (no event) if the slot is empty. + /// + public IReadOnlyList QuickLoad(int slot = DefaultSlot) + { + if (!_saveSlots.TryGetValue(slot, out var save)) + return []; + + _state.RestoreFromSave(save); + return [new StateRestoredEvent(new BoardSnapshot(_state), slot)]; + } + + public bool HasSave(int slot = DefaultSlot) => _saveSlots.ContainsKey(slot); } diff --git a/chessistics-tests/Simulation/QuickSaveTests.cs b/chessistics-tests/Simulation/QuickSaveTests.cs new file mode 100644 index 0000000..0a8f70c --- /dev/null +++ b/chessistics-tests/Simulation/QuickSaveTests.cs @@ -0,0 +1,181 @@ +using Chessistics.Engine.Commands; +using Chessistics.Engine.Events; +using Chessistics.Engine.Model; +using Chessistics.Tests.Helpers; +using Xunit; + +namespace Chessistics.Tests.Simulation; + +public class QuickSaveTests +{ + 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 QuickSave_ReturnsSavedEvent() + { + var sim = CreateSim(); + var events = sim.Sim.QuickSave(); + Assert.Single(events); + Assert.IsType(events[0]); + } + + [Fact] + public void QuickLoad_WithoutSave_ReturnsEmpty() + { + var sim = CreateSim(); + var events = sim.Sim.QuickLoad(); + Assert.Empty(events); + } + + [Fact] + public void QuickLoad_AfterSave_EmitsRestoredEvent() + { + var sim = CreateSim(); + sim.Sim.QuickSave(); + var events = sim.Sim.QuickLoad(); + Assert.Single(events); + var restored = Assert.IsType(events[0]); + Assert.NotNull(restored.Snapshot); + } + + [Fact] + public void Save_MutateState_Load_RestoresPreviousState() + { + var sim = CreateSim(); + + // Place a piece, then save + sim.Place(PieceKind.Rook, (0, 0), (1, 0)); + sim.Sim.QuickSave(); + + var beforeSnap = sim.Snapshot; + Assert.Single(beforeSnap.Pieces); + Assert.Equal(3 - 1, beforeSnap.RemainingStock[PieceKind.Rook]); + + // Mutate: place another piece, step simulation + sim.Place(PieceKind.Rook, (0, 1), (1, 1)); + sim.Step(); + sim.Step(); + + var dirtySnap = sim.Snapshot; + Assert.Equal(2, dirtySnap.Pieces.Count); + Assert.Equal(2, dirtySnap.TurnNumber); + + // Load: should match beforeSnap + sim.Sim.QuickLoad(); + var afterSnap = sim.Snapshot; + + Assert.Single(afterSnap.Pieces); + Assert.Equal(0, afterSnap.TurnNumber); + Assert.Equal(3 - 1, afterSnap.RemainingStock[PieceKind.Rook]); + Assert.Equal(beforeSnap.Pieces[0].Id, afterSnap.Pieces[0].Id); + Assert.Equal(beforeSnap.Pieces[0].StartCell, afterSnap.Pieces[0].StartCell); + } + + [Fact] + public void Load_IsIndependent_FromFurtherChanges() + { + // Saving should not alias; mutating state after save must not affect the save. + var sim = CreateSim(); + sim.Place(PieceKind.Rook, (0, 0), (1, 0)); + sim.Sim.QuickSave(); + + // Mutate after save + sim.Place(PieceKind.Rook, (0, 1), (1, 1)); + sim.Step(); + + // Load should restore to 1 piece, turn 0 + sim.Sim.QuickLoad(); + var snap = sim.Snapshot; + Assert.Single(snap.Pieces); + Assert.Equal(0, snap.TurnNumber); + + // Mutate again, load again — save still usable + sim.Place(PieceKind.Rook, (0, 2), (1, 2)); + sim.Sim.QuickLoad(); + snap = sim.Snapshot; + Assert.Single(snap.Pieces); + } + + [Fact] + public void QuickSave_PreservesCampaignProgression() + { + var campaign = CampaignBuilder(); + var sim = SimHelper.FromCampaign(campaign); + sim.Sim.ProcessCommand(new LoadCampaignCommand()); + + sim.Place(PieceKind.Pawn, (0, 0), (1, 0)); + sim.Sim.QuickSave(); + + var before = sim.Snapshot; + Assert.Equal(0, before.Campaign!.CurrentMissionIndex); + + // Mutate + sim.Step(); + sim.Step(); + + sim.Sim.QuickLoad(); + var after = sim.Snapshot; + Assert.Equal(before.Campaign!.CurrentMissionIndex, after.Campaign!.CurrentMissionIndex); + Assert.Equal(before.Pieces.Count, after.Pieces.Count); + Assert.Equal(0, after.TurnNumber); + } + + [Fact] + public void MultipleSlots_AreIndependent() + { + var sim = CreateSim(); + sim.Place(PieceKind.Rook, (0, 0), (1, 0)); + sim.Sim.QuickSave(slot: 1); + + sim.Place(PieceKind.Rook, (0, 1), (1, 1)); + sim.Sim.QuickSave(slot: 2); + + // Slot 1 should have 1 piece; slot 2 should have 2 + sim.Sim.QuickLoad(slot: 1); + Assert.Single(sim.Snapshot.Pieces); + + sim.Sim.QuickLoad(slot: 2); + Assert.Equal(2, sim.Snapshot.Pieces.Count); + } + + private static CampaignDef CampaignBuilder() + { + return new CampaignDef + { + Name = "TestCampaign", + InitialWidth = 4, + InitialHeight = 4, + Missions = new List + { + new() + { + Id = 1, + Name = "M1", + TerrainPatch = new TerrainPatch + { + NewWidth = 4, + NewHeight = 4, + Cells = new List + { + new() { Col = 0, Row = 0, Type = CellType.Production, + Production = new ProductionDef(new Coords(0, 0), "S", CargoType.Wood, 1) }, + new() { Col = 3, Row = 0, Type = CellType.Demand, + Demand = new DemandDef(new Coords(3, 0), "D", CargoType.Wood, 3) } + } + }, + UnlockedPieces = new List { PieceKind.Pawn }, + UnlockedLevels = new List { new(PieceKind.Pawn, 1) }, + Stock = new List { new(PieceKind.Pawn, 4) } + } + } + }; + } +} diff --git a/tools/automation/harness.py b/tools/automation/harness.py index 636466b..2d38664 100644 --- a/tools/automation/harness.py +++ b/tools/automation/harness.py @@ -268,6 +268,12 @@ class Harness: def back_to_menu(self) -> dict[str, Any]: return self.send("back_to_menu") + def quick_save(self) -> dict[str, Any]: + return self.send("quick_save") + + def quick_load(self) -> dict[str, Any]: + return self.send("quick_load") + def quit(self) -> dict[str, Any]: return self.send("quit", timeout=5.0) diff --git a/tools/automation/test_quicksave.py b/tools/automation/test_quicksave.py new file mode 100644 index 0000000..c4b3289 --- /dev/null +++ b/tools/automation/test_quicksave.py @@ -0,0 +1,48 @@ +"""End-to-end smoke test for quick save / quick load.""" +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="quicksave") as h: + h.load_mission("campaign_01", 0) + h.screenshot("01_loaded") + + initial = h.state() + print(f"[initial] turn={initial['turn']} pieces={len(initial['pieces'])}") + + # Save a clean checkpoint + saved = h.quick_save() + print(f"[quick_save] {saved}") + + # Mutate: place a piece and step + h.place("Pawn", (0, 0), (0, 1)) + h.screenshot("02_after_place") + h.set_speed(0.1) + h.play() + import time as _t + _t.sleep(1.5) + h.pause() + + dirty = h.state() + print(f"[dirty] turn={dirty['turn']} pieces={len(dirty['pieces'])}") + + # Load — should be back to initial state + loaded = h.quick_load() + print(f"[quick_load] {loaded}") + h.screenshot("03_after_load") + + restored = h.state() + print(f"[restored] turn={restored['turn']} pieces={len(restored['pieces'])}") + + assert restored['turn'] == initial['turn'], "Turn mismatch after restore" + assert len(restored['pieces']) == len(initial['pieces']), "Piece count mismatch" + print("OK — quick save/load roundtrip successful") + + +if __name__ == "__main__": + main()