From 24e72475b42f40930db77b2a015f23efed25ec29 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Fri, 19 May 2023 17:25:13 -0700 Subject: [PATCH] Checkpoint game before sim, auto-revert on abort. An alternative to https://github.com/dcs-liberation/dcs_liberation/pull/2891 that I ended up liking much better (I had assumed some part of the UI would fail or at least look terrible with this approach, but it seems to work quite well). On by default now since it's far less frightening than the previous thing. Fixes https://github.com/dcs-liberation/dcs_liberation/issues/2735. --- changelog.md | 2 +- game/persistence/savegamebundle.py | 18 +++++++++++++ game/persistence/savemanager.py | 7 +++++ game/settings/settings.py | 14 +++++----- game/sim/aircraftsimulation.py | 5 +--- game/sim/gameloop.py | 9 +------ game/sim/missionsimulation.py | 5 ---- qt_ui/simcontroller.py | 3 --- qt_ui/widgets/QTopPanel.py | 13 +++++++--- qt_ui/windows/QLiberationWindow.py | 26 ++++++++++++++++++- .../windows/QWaitingForMissionResultWindow.py | 8 +++--- tests/persistence/test_savegamebundle.py | 10 +++++++ 12 files changed, 84 insertions(+), 36 deletions(-) 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: