mirror of
https://github.com/dcs-liberation/dcs_liberation.git
synced 2025-11-10 14:22:26 +00:00
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:
parent
f10350dac4
commit
24e72475b4
@ -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.
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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]:
|
||||||
|
|||||||
@ -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()
|
||||||
|
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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()
|
||||||
|
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
@ -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")
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user