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.

(cherry picked from commit 24e72475b42f40930db77b2a015f23efed25ec29)
This commit is contained in:
Dan Albert 2023-05-19 17:25:13 -07:00
parent ff20f16109
commit c695db0f98
12 changed files with 84 additions and 36 deletions

View File

@ -16,7 +16,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.

View File

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

View File

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

View File

@ -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. <strong>DO NOT USE THIS WITH AUTO-RESOLVE ENABLED.</strong> 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(

View File

@ -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]:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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]
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: