diff --git a/changelog.md b/changelog.md index 23ecb08a..1c315c2c 100644 --- a/changelog.md +++ b/changelog.md @@ -24,7 +24,7 @@ Saves from 6.x are not compatible with 7.0. * **[Mission Generation]** Wind speeds no longer follow a uniform distribution. Median wind speeds are now much lower and the standard deviation has been reduced considerably at altitude but increased somewhat at MSL. * **[Mission Generation]** Improved task generation for SEAD flights carrying TALDs. * **[Mission Generation]** Added task timeout for SEAD flights with TALDs to prevent AI from overflying the target. -* **[Mission Generation]** Added (experimental!) option to rewind when aborting take off. This will allow you to modify your ATO after clicking take off without having to reload your game. +* **[Mission Generation]** Game state will automatically be checkpointed before fast-forwarding the mission, and restored on mission abort. This means that it's now possible to abort a mission and make changes without needing to manually re-load your game. * **[Modding]** Updated Community A-4E-C mod version support to 2.1.0 release. * **[Modding]** Add support for VSN F-4B and F-4C mod. * **[Modding]** Added support for AI C-47 mod. diff --git a/game/persistence/savegamebundle.py b/game/persistence/savegamebundle.py index d5aed5b9..92af1883 100644 --- a/game/persistence/savegamebundle.py +++ b/game/persistence/savegamebundle.py @@ -25,6 +25,7 @@ class SaveGameBundle: MANUAL_SAVE_NAME = "player.liberation" LAST_TURN_SAVE_NAME = "last_turn.liberation" START_OF_TURN_SAVE_NAME = "start_of_turn.liberation" + PRE_SIM_CHECKPOINT_SAVE_NAME = "pre_sim_checkpoint.liberation" def __init__(self, bundle_path: Path) -> None: self.bundle_path = bundle_path @@ -58,6 +59,19 @@ class SaveGameBundle: game, self.START_OF_TURN_SAVE_NAME, copy_from=self ) + def save_pre_sim_checkpoint(self, game: Game) -> None: + """Writes the save file for the state before beginning simulation. + + This save is the state of the game after the player presses "TAKE OFF", but + before the fast-forward simulation begins. It is not practical to rewind, but + players commonly will want to cancel and continue planning after pressing that + button, so we make a checkpoint that we can reload on abort. + """ + with logged_duration("Saving pre-sim checkpoint"): + self._update_bundle_member( + game, self.PRE_SIM_CHECKPOINT_SAVE_NAME, copy_from=self + ) + def load_player(self) -> Game: """Loads the save manually created by the player via save/save-as.""" return self._load_from(self.MANUAL_SAVE_NAME) @@ -70,6 +84,10 @@ class SaveGameBundle: """Loads the save automatically created at the end of the last turn.""" return self._load_from(self.LAST_TURN_SAVE_NAME) + def load_pre_sim_checkpoint(self) -> Game: + """Loads the save automatically created before the simulation began.""" + return self._load_from(self.PRE_SIM_CHECKPOINT_SAVE_NAME) + def _load_from(self, name: str) -> Game: with ZipFile(self.bundle_path) as zip_bundle: with zip_bundle.open(name, "r") as save: diff --git a/game/persistence/savemanager.py b/game/persistence/savemanager.py index 97f45a31..3e787330 100644 --- a/game/persistence/savemanager.py +++ b/game/persistence/savemanager.py @@ -51,6 +51,10 @@ class SaveManager: with self._save_bundle_context() as bundle: bundle.save_start_of_turn(self.game) + def save_pre_sim_checkpoint(self) -> None: + with self._save_bundle_context() as bundle: + bundle.save_pre_sim_checkpoint(self.game) + def set_loaded_from(self, bundle: SaveGameBundle) -> None: """Reconfigures this save manager based on the loaded game. @@ -81,6 +85,9 @@ class SaveManager: self.last_saved_bundle = previous_saved_bundle raise + def load_pre_sim_checkpoint(self) -> Game: + return self.default_save_bundle.load_pre_sim_checkpoint() + @staticmethod def load_last_turn(bundle_path: Path) -> Game: return SaveGameBundle(bundle_path).load_last_turn() diff --git a/game/settings/settings.py b/game/settings/settings.py index 80dbf3bd..c0c19876 100644 --- a/game/settings/settings.py +++ b/game/settings/settings.py @@ -324,17 +324,15 @@ class Settings: "modifications." ), ) - reset_simulation_on_abort: bool = boolean_option( - "Reset mission to pre-take off conditions on abort (experimental)", + reload_pre_sim_checkpoint_on_abort: bool = boolean_option( + "Reset mission to pre-take off conditions on abort", page=MISSION_GENERATOR_PAGE, section=GAMEPLAY_SECTION, - default=False, + default=True, detail=( - "If enabled, the fast-forward effects will be rewound when aborting take " - "off. DO NOT USE THIS WITH AUTO-RESOLVE ENABLED. Lost " - "aircraft will not be recovered. This option is experimental and may not " - "work. It is always safer to reload your save after abort when using fast-" - "forward." + "If enabled, the game will automatically reload a pre-take off save when " + "aborting take off. If this is disabled, you will need to manually re-load " + "your game after aborting take off." ), ) player_mission_interrupts_sim_at: Optional[StartType] = choices_option( diff --git a/game/sim/aircraftsimulation.py b/game/sim/aircraftsimulation.py index 846d11ae..89264148 100644 --- a/game/sim/aircraftsimulation.py +++ b/game/sim/aircraftsimulation.py @@ -69,12 +69,9 @@ class AircraftSimulation: for flight in self.iter_flights(): flight.state.reinitialize(now) - def reset(self, events: GameUpdateEvents | None = None) -> None: - if events is None: - events = GameUpdateEvents() + def reset(self) -> None: for flight in self.iter_flights(): flight.set_state(Uninitialized(flight, self.game.settings)) - events.update_flight(flight) self.combats = [] def iter_flights(self) -> Iterator[Flight]: diff --git a/game/sim/gameloop.py b/game/sim/gameloop.py index 6b2d498e..a1588665 100644 --- a/game/sim/gameloop.py +++ b/game/sim/gameloop.py @@ -36,17 +36,10 @@ class GameLoop: def elapsed_time(self) -> timedelta: return self.sim.time - self.game.conditions.start_time - def reset(self) -> None: - self.pause() - self.events = GameUpdateEvents() - self.sim.reset(self.events) - self.send_update(rate_limit=False) - self.started = False - self.completed = False - def start(self) -> None: if self.started: raise RuntimeError("Cannot start game loop because it has already started") + self.game.save_manager.save_pre_sim_checkpoint() self.started = True self.sim.begin_simulation() diff --git a/game/sim/missionsimulation.py b/game/sim/missionsimulation.py index f3dc39d1..cef32e19 100644 --- a/game/sim/missionsimulation.py +++ b/game/sim/missionsimulation.py @@ -33,11 +33,6 @@ class MissionSimulation: self.completed = False self.time = self.game.conditions.start_time - def reset(self, events: GameUpdateEvents) -> None: - self.completed = False - self.time = self.game.conditions.start_time - self.aircraft_simulation.reset(events) - def begin_simulation(self) -> None: self.time = self.game.conditions.start_time self.aircraft_simulation.begin_simulation() diff --git a/qt_ui/simcontroller.py b/qt_ui/simcontroller.py index 45f8e0cd..18675139 100644 --- a/qt_ui/simcontroller.py +++ b/qt_ui/simcontroller.py @@ -84,9 +84,6 @@ class SimController(QObject): with self.game_loop.paused_sim(): yield - def reset_simulation(self) -> None: - self.game_loop.reset() - def run_to_first_contact(self) -> None: self.game_loop.run_to_first_contact() diff --git a/qt_ui/widgets/QTopPanel.py b/qt_ui/widgets/QTopPanel.py index 156e5e9b..3eed7096 100644 --- a/qt_ui/widgets/QTopPanel.py +++ b/qt_ui/widgets/QTopPanel.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import List, Optional +from typing import List, Optional, Callable from PySide6.QtWidgets import ( QDialog, @@ -33,11 +33,16 @@ from qt_ui.windows.QWaitingForMissionResultWindow import QWaitingForMissionResul class QTopPanel(QFrame): def __init__( - self, game_model: GameModel, sim_controller: SimController, ui_flags: UiFlags + self, + game_model: GameModel, + sim_controller: SimController, + ui_flags: UiFlags, + reset_to_pre_sim_checkpoint: Callable[[], None], ) -> None: super(QTopPanel, self).__init__() self.game_model = game_model self.sim_controller = sim_controller + self.reset_to_pre_sim_checkpoint = reset_to_pre_sim_checkpoint self.dialog: Optional[QDialog] = None self.setMaximumHeight(70) @@ -293,7 +298,9 @@ class QTopPanel(QFrame): persistence.mission_path_for("liberation_nextturn.miz") ) - waiting = QWaitingForMissionResultWindow(self.game, self.sim_controller, self) + waiting = QWaitingForMissionResultWindow( + self.game, self.sim_controller, self.reset_to_pre_sim_checkpoint, self + ) waiting.exec_() def budget_update(self, game: Game): diff --git a/qt_ui/windows/QLiberationWindow.py b/qt_ui/windows/QLiberationWindow.py index c2a1de16..5372cd8b 100644 --- a/qt_ui/windows/QLiberationWindow.py +++ b/qt_ui/windows/QLiberationWindow.py @@ -133,7 +133,14 @@ class QLiberationWindow(QMainWindow): vbox = QVBoxLayout() vbox.setContentsMargins(0, 0, 0, 0) - vbox.addWidget(QTopPanel(self.game_model, self.sim_controller, ui_flags)) + vbox.addWidget( + QTopPanel( + self.game_model, + self.sim_controller, + ui_flags, + self.reset_to_pre_sim_checkpoint, + ) + ) vbox.addWidget(hbox) central_widget = QWidget() @@ -340,6 +347,23 @@ class QLiberationWindow(QMainWindow): except Exception: logging.exception("Error loading save game %s", file[0]) + def reset_to_pre_sim_checkpoint(self) -> None: + """Loads the game that was saved before pressing the take-off button. + + A checkpoint will be saved when the player presses take-off to save their state + before the mission simulation begins. If the mission is aborted, we usually want + to reset to the pre-simulation state to allow players to effectively "rewind", + since they probably aborted so that they could make changes. Implementing rewind + for real is impractical, but checkpoints are easy. + """ + if self.game is None: + raise RuntimeError( + "Cannot reset to pre-sim checkpoint when no game is loaded" + ) + GameUpdateSignal.get_instance().game_loaded.emit( + self.game.save_manager.load_pre_sim_checkpoint() + ) + def saveGame(self): logging.info("Saving game") diff --git a/qt_ui/windows/QWaitingForMissionResultWindow.py b/qt_ui/windows/QWaitingForMissionResultWindow.py index a4c76906..f26742b3 100644 --- a/qt_ui/windows/QWaitingForMissionResultWindow.py +++ b/qt_ui/windows/QWaitingForMissionResultWindow.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging from pathlib import Path -from typing import Optional +from typing import Optional, Callable from PySide6 import QtCore from PySide6.QtCore import QObject, Signal @@ -52,12 +52,14 @@ class QWaitingForMissionResultWindow(QDialog): self, game: Game, sim_controller: SimController, + reset_to_pre_sim_checkpoint: Callable[[], None], parent: Optional[QWidget] = None, ) -> None: super(QWaitingForMissionResultWindow, self).__init__(parent=parent) self.setWindowModality(QtCore.Qt.WindowModal) self.game = game self.sim_controller = sim_controller + self.reset_to_pre_sim_checkpoint = reset_to_pre_sim_checkpoint self.setWindowTitle("Waiting for mission completion.") self.setWindowIcon(QIcon("./resources/icon.png")) self.setMinimumHeight(570) @@ -134,8 +136,8 @@ class QWaitingForMissionResultWindow(QDialog): self.setLayout(self.layout) def reject(self) -> None: - if self.game.settings.reset_simulation_on_abort: - self.sim_controller.reset_simulation() + if self.game.settings.reload_pre_sim_checkpoint_on_abort: + self.reset_to_pre_sim_checkpoint() super().reject() @staticmethod diff --git a/tests/persistence/test_savegamebundle.py b/tests/persistence/test_savegamebundle.py index 19e81054..a31a8d26 100644 --- a/tests/persistence/test_savegamebundle.py +++ b/tests/persistence/test_savegamebundle.py @@ -56,6 +56,16 @@ def test_save_start_of_turn(game: Game, tmp_bundle: SaveGameBundle) -> None: assert zip_file.namelist() == [SaveGameBundle.START_OF_TURN_SAVE_NAME] +def test_save_pre_sim_checkpoint(game: Game, tmp_bundle: SaveGameBundle) -> None: + with ZipFile(tmp_bundle.bundle_path, "r") as zip_file: + with pytest.raises(KeyError): + zip_file.read(SaveGameBundle.PRE_SIM_CHECKPOINT_SAVE_NAME) + tmp_bundle.save_pre_sim_checkpoint(game) + + with ZipFile(tmp_bundle.bundle_path, "r") as zip_file: + assert zip_file.namelist() == [SaveGameBundle.PRE_SIM_CHECKPOINT_SAVE_NAME] + + def test_failed_save_leaves_original_intact( game: Game, tmp_bundle: SaveGameBundle ) -> None: