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.
This commit is contained in:
Dan Albert 2023-05-19 17:25:13 -07:00
parent f10350dac4
commit 24e72475b4
12 changed files with 84 additions and 36 deletions

View File

@ -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]** 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]** 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 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]** 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]** Add support for VSN F-4B and F-4C mod.
* **[Modding]** Added support for AI C-47 mod. * **[Modding]** Added support for AI C-47 mod.

View File

@ -25,6 +25,7 @@ class SaveGameBundle:
MANUAL_SAVE_NAME = "player.liberation" MANUAL_SAVE_NAME = "player.liberation"
LAST_TURN_SAVE_NAME = "last_turn.liberation" LAST_TURN_SAVE_NAME = "last_turn.liberation"
START_OF_TURN_SAVE_NAME = "start_of_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: def __init__(self, bundle_path: Path) -> None:
self.bundle_path = bundle_path self.bundle_path = bundle_path
@ -58,6 +59,19 @@ class SaveGameBundle:
game, self.START_OF_TURN_SAVE_NAME, copy_from=self 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: def load_player(self) -> Game:
"""Loads the save manually created by the player via save/save-as.""" """Loads the save manually created by the player via save/save-as."""
return self._load_from(self.MANUAL_SAVE_NAME) 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.""" """Loads the save automatically created at the end of the last turn."""
return self._load_from(self.LAST_TURN_SAVE_NAME) 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: def _load_from(self, name: str) -> Game:
with ZipFile(self.bundle_path) as zip_bundle: with ZipFile(self.bundle_path) as zip_bundle:
with zip_bundle.open(name, "r") as save: with zip_bundle.open(name, "r") as save:

View File

@ -51,6 +51,10 @@ class SaveManager:
with self._save_bundle_context() as bundle: with self._save_bundle_context() as bundle:
bundle.save_start_of_turn(self.game) 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: def set_loaded_from(self, bundle: SaveGameBundle) -> None:
"""Reconfigures this save manager based on the loaded game. """Reconfigures this save manager based on the loaded game.
@ -81,6 +85,9 @@ class SaveManager:
self.last_saved_bundle = previous_saved_bundle self.last_saved_bundle = previous_saved_bundle
raise raise
def load_pre_sim_checkpoint(self) -> Game:
return self.default_save_bundle.load_pre_sim_checkpoint()
@staticmethod @staticmethod
def load_last_turn(bundle_path: Path) -> Game: def load_last_turn(bundle_path: Path) -> Game:
return SaveGameBundle(bundle_path).load_last_turn() return SaveGameBundle(bundle_path).load_last_turn()

View File

@ -324,17 +324,15 @@ class Settings:
"modifications." "modifications."
), ),
) )
reset_simulation_on_abort: bool = boolean_option( reload_pre_sim_checkpoint_on_abort: bool = boolean_option(
"Reset mission to pre-take off conditions on abort (experimental)", "Reset mission to pre-take off conditions on abort",
page=MISSION_GENERATOR_PAGE, page=MISSION_GENERATOR_PAGE,
section=GAMEPLAY_SECTION, section=GAMEPLAY_SECTION,
default=False, default=True,
detail=( detail=(
"If enabled, the fast-forward effects will be rewound when aborting take " "If enabled, the game will automatically reload a pre-take off save when "
"off. <strong>DO NOT USE THIS WITH AUTO-RESOLVE ENABLED.</strong> Lost " "aborting take off. If this is disabled, you will need to manually re-load "
"aircraft will not be recovered. This option is experimental and may not " "your game after aborting take off."
"work. It is always safer to reload your save after abort when using fast-"
"forward."
), ),
) )
player_mission_interrupts_sim_at: Optional[StartType] = choices_option( player_mission_interrupts_sim_at: Optional[StartType] = choices_option(

View File

@ -69,12 +69,9 @@ class AircraftSimulation:
for flight in self.iter_flights(): for flight in self.iter_flights():
flight.state.reinitialize(now) flight.state.reinitialize(now)
def reset(self, events: GameUpdateEvents | None = None) -> None: def reset(self) -> None:
if events is None:
events = GameUpdateEvents()
for flight in self.iter_flights(): for flight in self.iter_flights():
flight.set_state(Uninitialized(flight, self.game.settings)) flight.set_state(Uninitialized(flight, self.game.settings))
events.update_flight(flight)
self.combats = [] self.combats = []
def iter_flights(self) -> Iterator[Flight]: def iter_flights(self) -> Iterator[Flight]:

View File

@ -36,17 +36,10 @@ class GameLoop:
def elapsed_time(self) -> timedelta: def elapsed_time(self) -> timedelta:
return self.sim.time - self.game.conditions.start_time 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: def start(self) -> None:
if self.started: if self.started:
raise RuntimeError("Cannot start game loop because it has already started") raise RuntimeError("Cannot start game loop because it has already started")
self.game.save_manager.save_pre_sim_checkpoint()
self.started = True self.started = True
self.sim.begin_simulation() self.sim.begin_simulation()

View File

@ -33,11 +33,6 @@ class MissionSimulation:
self.completed = False self.completed = False
self.time = self.game.conditions.start_time 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: def begin_simulation(self) -> None:
self.time = self.game.conditions.start_time self.time = self.game.conditions.start_time
self.aircraft_simulation.begin_simulation() self.aircraft_simulation.begin_simulation()

View File

@ -84,9 +84,6 @@ class SimController(QObject):
with self.game_loop.paused_sim(): with self.game_loop.paused_sim():
yield yield
def reset_simulation(self) -> None:
self.game_loop.reset()
def run_to_first_contact(self) -> None: def run_to_first_contact(self) -> None:
self.game_loop.run_to_first_contact() self.game_loop.run_to_first_contact()

View File

@ -1,5 +1,5 @@
from datetime import datetime from datetime import datetime
from typing import List, Optional from typing import List, Optional, Callable
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QDialog, QDialog,
@ -33,11 +33,16 @@ from qt_ui.windows.QWaitingForMissionResultWindow import QWaitingForMissionResul
class QTopPanel(QFrame): class QTopPanel(QFrame):
def __init__( 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: ) -> None:
super(QTopPanel, self).__init__() super(QTopPanel, self).__init__()
self.game_model = game_model self.game_model = game_model
self.sim_controller = sim_controller self.sim_controller = sim_controller
self.reset_to_pre_sim_checkpoint = reset_to_pre_sim_checkpoint
self.dialog: Optional[QDialog] = None self.dialog: Optional[QDialog] = None
self.setMaximumHeight(70) self.setMaximumHeight(70)
@ -293,7 +298,9 @@ class QTopPanel(QFrame):
persistence.mission_path_for("liberation_nextturn.miz") 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_() waiting.exec_()
def budget_update(self, game: Game): def budget_update(self, game: Game):

View File

@ -133,7 +133,14 @@ class QLiberationWindow(QMainWindow):
vbox = QVBoxLayout() vbox = QVBoxLayout()
vbox.setContentsMargins(0, 0, 0, 0) 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) vbox.addWidget(hbox)
central_widget = QWidget() central_widget = QWidget()
@ -340,6 +347,23 @@ class QLiberationWindow(QMainWindow):
except Exception: except Exception:
logging.exception("Error loading save game %s", file[0]) 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): def saveGame(self):
logging.info("Saving game") logging.info("Saving game")

View File

@ -2,7 +2,7 @@ from __future__ import annotations
import logging import logging
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional, Callable
from PySide6 import QtCore from PySide6 import QtCore
from PySide6.QtCore import QObject, Signal from PySide6.QtCore import QObject, Signal
@ -52,12 +52,14 @@ class QWaitingForMissionResultWindow(QDialog):
self, self,
game: Game, game: Game,
sim_controller: SimController, sim_controller: SimController,
reset_to_pre_sim_checkpoint: Callable[[], None],
parent: Optional[QWidget] = None, parent: Optional[QWidget] = None,
) -> None: ) -> None:
super(QWaitingForMissionResultWindow, self).__init__(parent=parent) super(QWaitingForMissionResultWindow, self).__init__(parent=parent)
self.setWindowModality(QtCore.Qt.WindowModal) self.setWindowModality(QtCore.Qt.WindowModal)
self.game = game self.game = game
self.sim_controller = sim_controller self.sim_controller = sim_controller
self.reset_to_pre_sim_checkpoint = reset_to_pre_sim_checkpoint
self.setWindowTitle("Waiting for mission completion.") self.setWindowTitle("Waiting for mission completion.")
self.setWindowIcon(QIcon("./resources/icon.png")) self.setWindowIcon(QIcon("./resources/icon.png"))
self.setMinimumHeight(570) self.setMinimumHeight(570)
@ -134,8 +136,8 @@ class QWaitingForMissionResultWindow(QDialog):
self.setLayout(self.layout) self.setLayout(self.layout)
def reject(self) -> None: def reject(self) -> None:
if self.game.settings.reset_simulation_on_abort: if self.game.settings.reload_pre_sim_checkpoint_on_abort:
self.sim_controller.reset_simulation() self.reset_to_pre_sim_checkpoint()
super().reject() super().reject()
@staticmethod @staticmethod

View File

@ -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] 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( def test_failed_save_leaves_original_intact(
game: Game, tmp_bundle: SaveGameBundle game: Game, tmp_bundle: SaveGameBundle
) -> None: ) -> None: