Add QuickSave/QuickLoad with full state restore and visual rebuild

BoardState.CaptureSave/RestoreFromSave deep-copy every mutable field
(grid, pieces, demands, transformers, buffers, stock, campaign progress)
into a WorldSave slot. GameSim.QuickSave/QuickLoad expose slotted saves
and emit StateSavedEvent / StateRestoredEvent — the latter carries a
fresh BoardSnapshot so the presentation can rebuild board, pieces,
trajectories, objectives, stock, camera, and control bar in one pass.

F5/F9 trigger it in Main; harness gains quick_save/quick_load commands so
UI tests can checkpoint a scenario and resume without replaying from
scratch. Seven xUnit tests cover the roundtrip (including independence
from post-save mutations, campaign state, and multi-slot isolation).
This commit is contained in:
Samuel Bouchet 2026-04-17 22:10:06 +02:00
parent bd1763f372
commit 2537bfe828
12 changed files with 521 additions and 5 deletions

View file

@ -27,6 +27,8 @@ internal class AutomationFacade
public Action BackToMenu { get; }
public Action<float> SetSpeed { get; }
public Action Quit { get; }
public Action QuickSave { get; }
public Action QuickLoad { get; }
public AutomationFacade(
Func<GameSim?> sim,
@ -41,7 +43,9 @@ internal class AutomationFacade
Action togglePlayPause,
Action backToMenu,
Action<float> 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;
}
}

View file

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

View file

@ -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,11 +183,24 @@ 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)
{
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();
}
}
}
public override void _Process(double delta)
@ -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

View file

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

View file

@ -222,6 +222,100 @@ public class BoardState
}
}
/// <summary>
/// Capture a deep copy of every mutable field (for QuickSave).
/// Immutable defs and CampaignDef are shared by reference.
/// </summary>
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<Coords, ProductionDef>(Productions),
ProductionBuffers = new Dictionary<Coords, int>(ProductionBuffers),
Demands = Demands.ToDictionary(kv => kv.Key, kv => kv.Value.Clone()),
Transformers = new Dictionary<Coords, TransformerDef>(Transformers),
TransformerInputBuffers = new Dictionary<Coords, int>(TransformerInputBuffers),
TransformerOutputBuffers = new Dictionary<Coords, int>(TransformerOutputBuffers),
Pieces = Pieces.Select(p => p.Clone()).ToList(),
DestroyedPieces = DestroyedPieces.Select(p => p.Clone()).ToList(),
RemainingStock = new Dictionary<PieceKind, int>(RemainingStock),
OccupiedCells = new HashSet<Coords>(OccupiedCells),
Campaign = Campaign == null ? null : new CampaignSaveData
{
CurrentMissionIndex = Campaign.CurrentMissionIndex,
CompletedMissions = new List<int>(Campaign.CompletedMissions),
AvailablePieceKinds = new HashSet<PieceKind>(Campaign.AvailablePieceKinds),
AvailableLevels = new HashSet<PieceUpgrade>(Campaign.AvailableLevels)
}
};
}
/// <summary>
/// Restore every mutable field from a save. Board dimensions can grow
/// or shrink; the grid is fully replaced.
/// </summary>
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);

View file

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

View file

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

View file

@ -0,0 +1,40 @@
namespace Chessistics.Engine.Model;
/// <summary>
/// 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.
/// </summary>
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<Coords, ProductionDef> Productions { get; init; } = new();
public Dictionary<Coords, int> ProductionBuffers { get; init; } = new();
public Dictionary<Coords, DemandState> Demands { get; init; } = new();
public Dictionary<Coords, TransformerDef> Transformers { get; init; } = new();
public Dictionary<Coords, int> TransformerInputBuffers { get; init; } = new();
public Dictionary<Coords, int> TransformerOutputBuffers { get; init; } = new();
public List<PieceState> Pieces { get; init; } = new();
public List<PieceState> DestroyedPieces { get; init; } = new();
public Dictionary<PieceKind, int> RemainingStock { get; init; } = new();
public HashSet<Coords> OccupiedCells { get; init; } = new();
public CampaignSaveData? Campaign { get; init; }
}
public sealed class CampaignSaveData
{
public int CurrentMissionIndex { get; init; }
public List<int> CompletedMissions { get; init; } = new();
public HashSet<PieceKind> AvailablePieceKinds { get; init; } = new();
public HashSet<PieceUpgrade> AvailableLevels { get; init; } = new();
}

View file

@ -7,6 +7,8 @@ namespace Chessistics.Engine.Simulation;
public class GameSim
{
private readonly BoardState _state;
private readonly Dictionary<int, WorldSave> _saveSlots = new();
private const int DefaultSlot = 0;
public GameSim(LevelDef level)
{
@ -33,4 +35,30 @@ public class GameSim
}
public BoardSnapshot GetSnapshot() => new(_state);
/// <summary>
/// Capture a full deep-copy of the world into an in-memory slot.
/// Returns a single StateSavedEvent — no state mutation.
/// </summary>
public IReadOnlyList<IWorldEvent> QuickSave(int slot = DefaultSlot)
{
_saveSlots[slot] = _state.CaptureSave();
return [new StateSavedEvent(_state.TurnNumber, slot)];
}
/// <summary>
/// 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.
/// </summary>
public IReadOnlyList<IWorldEvent> 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);
}

View file

@ -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<StateSavedEvent>(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<StateRestoredEvent>(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<MissionDef>
{
new()
{
Id = 1,
Name = "M1",
TerrainPatch = new TerrainPatch
{
NewWidth = 4,
NewHeight = 4,
Cells = new List<PatchCell>
{
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> { PieceKind.Pawn },
UnlockedLevels = new List<PieceUpgrade> { new(PieceKind.Pawn, 1) },
Stock = new List<PieceStock> { new(PieceKind.Pawn, 4) }
}
}
};
}
}

View file

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

View file

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