diff --git a/changelog.md b/changelog.md index 36afee9b..72f1ff48 100644 --- a/changelog.md +++ b/changelog.md @@ -15,6 +15,7 @@ Saves from 6.x are not compatible with 7.0. * **[Modding]** Custom factions can now be defined in YAML as well as JSON. JSON support may be removed in the future if having both formats causes confusion. * **[Modding]** Campaigns which require custom factions can now define those factions directly in the campaign YAML. See Operation Aliied Sword for an example. * **[Modding]** The `mission_types` field in squadron files has been removed. Squadron task capability is now determined by airframe, and the auto-assignable list has always been overridden by the campaign settings. +* **[New Game Wizard]** Some game settings are now saved to be reused for the next game. At present this is a small set (just supercarrier and auto-purchase), but it will be expanded later. * **[Squadrons]** Squadron-specific mission capability lists no longer restrict players from assigning missions outside the squadron's preferences. ## Fixes diff --git a/game/persistence/paths.py b/game/persistence/paths.py index 004e0bc5..d32cfb44 100644 --- a/game/persistence/paths.py +++ b/game/persistence/paths.py @@ -18,8 +18,13 @@ def base_path() -> str: return str(_dcs_saved_game_folder) +def liberation_user_dir() -> Path: + """The path to the Liberation user directory.""" + return Path(base_path()) / "Liberation" + + def save_dir() -> Path: - return Path(base_path()) / "Liberation" / "Saves" + return liberation_user_dir() / "Saves" def mission_path_for(name: str) -> Path: diff --git a/game/settings/booleanoption.py b/game/settings/booleanoption.py index 4149d1ec..4471b663 100644 --- a/game/settings/booleanoption.py +++ b/game/settings/booleanoption.py @@ -18,6 +18,7 @@ def boolean_option( detail: Optional[str] = None, tooltip: Optional[str] = None, causes_expensive_game_update: bool = False, + remember_player_choice: bool = False, **kwargs: Any, ) -> bool: return field( @@ -29,6 +30,7 @@ def boolean_option( detail, tooltip, causes_expensive_game_update, + remember_player_choice, invert, ) }, diff --git a/game/settings/boundedfloatoption.py b/game/settings/boundedfloatoption.py index 6f9bb3e6..a47c4d2d 100644 --- a/game/settings/boundedfloatoption.py +++ b/game/settings/boundedfloatoption.py @@ -21,6 +21,7 @@ def bounded_float_option( divisor: int, detail: Optional[str] = None, tooltip: Optional[str] = None, + remember_player_choice: bool = False, **kwargs: Any, ) -> float: return field( @@ -32,6 +33,7 @@ def bounded_float_option( detail, tooltip, causes_expensive_game_update=False, + remember_player_choice=remember_player_choice, min=min, max=max, divisor=divisor, diff --git a/game/settings/boundedintoption.py b/game/settings/boundedintoption.py index a716ac0b..d8de49fa 100644 --- a/game/settings/boundedintoption.py +++ b/game/settings/boundedintoption.py @@ -20,6 +20,7 @@ def bounded_int_option( detail: Optional[str] = None, tooltip: Optional[str] = None, causes_expensive_game_update: bool = False, + remember_player_choice: bool = False, **kwargs: Any, ) -> int: return field( @@ -31,6 +32,7 @@ def bounded_int_option( detail, tooltip, causes_expensive_game_update, + remember_player_choice, min=min, max=max, ) diff --git a/game/settings/choicesoption.py b/game/settings/choicesoption.py index 323d1b54..4b97b04a 100644 --- a/game/settings/choicesoption.py +++ b/game/settings/choicesoption.py @@ -38,6 +38,9 @@ def choices_option( detail, tooltip, causes_expensive_game_update=False, + # Same as minutes_option, this requires custom serialization before we + # can do this. + remember_player_choice=False, choices=dict(choices), ) }, diff --git a/game/settings/minutesoption.py b/game/settings/minutesoption.py index 4d4b3759..5da5f6c7 100644 --- a/game/settings/minutesoption.py +++ b/game/settings/minutesoption.py @@ -31,6 +31,11 @@ def minutes_option( detail, tooltip, causes_expensive_game_update=False, + # Can't preserve timedelta until we create some custom serialization for + # it. The default serialization is as a python object, which isn't + # allowed in yaml.safe_load because a malicious modification of the + # settings file would be able to execute arbitrary code. + remember_player_choice=False, min=min, max=max, ) diff --git a/game/settings/optiondescription.py b/game/settings/optiondescription.py index 0963ce61..4db0cdf0 100644 --- a/game/settings/optiondescription.py +++ b/game/settings/optiondescription.py @@ -13,3 +13,10 @@ class OptionDescription: detail: Optional[str] tooltip: Optional[str] causes_expensive_game_update: bool + + # If True, the player's selection for this value will be saved to settings.yaml in + # the Liberation user directory when a new game is created, and those values will be + # used by default for new games. This is conditional because many settings are not + # appropriate for cross-game persistence (economy settings are, for example, usually + # hinted by the campaign itself). + remember_player_choice: bool diff --git a/game/settings/settings.py b/game/settings/settings.py index 2dd3934a..ea3b9bff 100644 --- a/game/settings/settings.py +++ b/game/settings/settings.py @@ -1,9 +1,12 @@ +import logging from collections.abc import Iterator from dataclasses import Field, dataclass, field, fields from datetime import timedelta from enum import Enum, unique -from typing import Any, Dict, Optional +from pathlib import Path +from typing import Any, Dict, Optional, get_type_hints +import yaml from dcs.forcedoptions import ForcedOptions from .booleanoption import boolean_option @@ -14,6 +17,7 @@ from .minutesoption import minutes_option from .optiondescription import OptionDescription, SETTING_DESCRIPTION_KEY from .skilloption import skill_option from ..ato.starttype import StartType +from ..persistence.paths import liberation_user_dir @unique @@ -255,18 +259,21 @@ class Settings: CAMPAIGN_MANAGEMENT_PAGE, HQ_AUTOMATION_SECTION, default=False, + remember_player_choice=True, ) automate_front_line_reinforcements: bool = boolean_option( "Automate front-line purchases", CAMPAIGN_MANAGEMENT_PAGE, HQ_AUTOMATION_SECTION, default=False, + remember_player_choice=True, ) automate_aircraft_reinforcements: bool = boolean_option( "Automate aircraft purchases", CAMPAIGN_MANAGEMENT_PAGE, HQ_AUTOMATION_SECTION, default=False, + remember_player_choice=True, ) auto_ato_behavior: AutoAtoBehavior = choices_option( "Automatic package planning behavior", @@ -340,6 +347,7 @@ class Settings: MISSION_GENERATOR_PAGE, GAMEPLAY_SECTION, default=False, + remember_player_choice=True, ) generate_marks: bool = boolean_option( "Put objective markers on the map", @@ -487,6 +495,59 @@ class Settings: only_player_takeoff: bool = True # Legacy parameter do not use + def save_player_settings(self) -> None: + """Saves the player's global settings to the user directory.""" + settings: dict[str, Any] = {} + for name, description in self.all_fields(): + if description.remember_player_choice: + settings[name] = self.__dict__[name] + + # Plugin settings are currently not handled. It looks like the PluginManager + # probably needs some changes to do so safely. When injecting plugin options + # into Settings with LuaPluginManager.load_settings, it sets a reference to the + # passed Settings object. The lifetime is unclear, but it might be the case that + # canceling the NGW would leave LuaPluginManager in a bad state (yes, we could + # reset it on cancel, but that's just one bug I've thought of and without better + # understanding of how that works it's hard to say where more could be lurking). + + with self._player_settings_file.open("w", encoding="utf-8") as settings_file: + yaml.dump(settings, settings_file, sort_keys=False, explicit_start=True) + + def merge_player_settings(self) -> None: + """Updates with the player's global settings.""" + settings_path = self._player_settings_file + if not settings_path.exists(): + return + with settings_path.open(encoding="utf-8") as settings_file: + data = yaml.safe_load(settings_file) + + expected_types = get_type_hints(Settings) + for key, value in data.items(): + if key not in self.__dict__: + logging.warning( + "Unexpected settings key found in %s: %s. Ignoring.", + settings_path, + key, + ) + continue + + expected_type = expected_types[key] + if not isinstance(value, expected_type): + logging.error( + "%s in %s does not have the expected type %s (is %s). Ignoring.", + key, + settings_path, + expected_type.__name__, + value.__class__.__name__, + ) + continue + self.__dict__[key] = value + + @property + def _player_settings_file(self) -> Path: + """Returns the path to the player's global settings file.""" + return liberation_user_dir() / "settings.yaml" + @staticmethod def plugin_settings_key(identifier: str) -> str: return f"plugins.{identifier}" @@ -536,11 +597,17 @@ class Settings: seen.add(description.section) @classmethod - def fields(cls, page: str, section: str) -> Iterator[tuple[str, OptionDescription]]: + def all_fields(cls) -> Iterator[tuple[str, OptionDescription]]: for settings_field in cls._user_fields(): - description = cls._field_description(settings_field) + yield settings_field.name, cls._field_description(settings_field) + + @classmethod + def fields_for( + cls, page: str, section: str + ) -> Iterator[tuple[str, OptionDescription]]: + for name, description in cls.all_fields(): if description.page == page and description.section == section: - yield settings_field.name, description + yield name, description @classmethod def _user_fields(cls) -> Iterator[Field[Any]]: diff --git a/qt_ui/windows/newgame/QNewGameWizard.py b/qt_ui/windows/newgame/QNewGameWizard.py index 3c302b96..6b5eda7a 100644 --- a/qt_ui/windows/newgame/QNewGameWizard.py +++ b/qt_ui/windows/newgame/QNewGameWizard.py @@ -30,8 +30,6 @@ jinja_env = Environment( lstrip_blocks=True, ) -DEFAULT_MISSION_LENGTH: timedelta = timedelta(minutes=60) - """ Possible time periods for new games @@ -83,6 +81,12 @@ class NewGameWizard(QtWidgets.QWizard): def __init__(self, parent=None): super(NewGameWizard, self).__init__(parent) + # The wizard should probably be refactored to edit this directly, but for now we + # just create a Settings object so that we can load the player's preserved + # defaults. We'll recreate a new settings and merge in the wizard options later. + default_settings = Settings() + default_settings.merge_player_settings() + factions = Factions.load() self.campaigns = list(sorted(Campaign.load_each(), key=lambda x: x.name)) @@ -94,8 +98,8 @@ class NewGameWizard(QtWidgets.QWizard): ) self.addPage(self.theater_page) self.addPage(self.faction_selection_page) - self.addPage(GeneratorOptions()) - self.difficulty_page = DifficultyAndAutomationOptions() + self.addPage(GeneratorOptions(default_settings)) + self.difficulty_page = DifficultyAndAutomationOptions(default_settings) # Update difficulty page on campaign select self.theater_page.campaign_selected.connect( @@ -146,6 +150,7 @@ class NewGameWizard(QtWidgets.QWizard): automate_aircraft_reinforcements=self.field("automate_aircraft_purchases"), supercarrier=self.field("supercarrier"), ) + settings.save_player_settings() generator_settings = GeneratorSettings( start_date=start_date, start_time=campaign.recommended_start_time, @@ -540,7 +545,7 @@ class BudgetInputs(QtWidgets.QGridLayout): class DifficultyAndAutomationOptions(QtWidgets.QWizardPage): - def __init__(self, parent=None) -> None: + def __init__(self, default_settings: Settings, parent=None) -> None: super().__init__(parent) self.setTitle("Difficulty and automation options") @@ -584,16 +589,19 @@ class DifficultyAndAutomationOptions(QtWidgets.QWizardPage): assist_layout.addWidget(QtWidgets.QLabel("Automate runway repairs"), 0, 0) runway_repairs = QtWidgets.QCheckBox() + runway_repairs.setChecked(default_settings.automate_runway_repair) self.registerField("automate_runway_repairs", runway_repairs) assist_layout.addWidget(runway_repairs, 0, 1, Qt.AlignRight) assist_layout.addWidget(QtWidgets.QLabel("Automate front-line purchases"), 1, 0) front_line = QtWidgets.QCheckBox() + front_line.setChecked(default_settings.automate_front_line_reinforcements) self.registerField("automate_front_line_purchases", front_line) assist_layout.addWidget(front_line, 1, 1, Qt.AlignRight) assist_layout.addWidget(QtWidgets.QLabel("Automate aircraft purchases"), 2, 0) aircraft = QtWidgets.QCheckBox() + aircraft.setChecked(default_settings.automate_aircraft_reinforcements) self.registerField("automate_aircraft_purchases", aircraft) assist_layout.addWidget(aircraft, 2, 1, Qt.AlignRight) @@ -611,7 +619,7 @@ class DifficultyAndAutomationOptions(QtWidgets.QWizardPage): class GeneratorOptions(QtWidgets.QWizardPage): - def __init__(self, parent=None): + def __init__(self, default_settings: Settings, parent=None): super().__init__(parent) self.setTitle("Generator settings") self.setSubTitle("\nOptions affecting the generation of the game.") @@ -627,13 +635,14 @@ class GeneratorOptions(QtWidgets.QWizardPage): no_lha = QtWidgets.QCheckBox() self.registerField("no_lha", no_lha) supercarrier = QtWidgets.QCheckBox() + supercarrier.setChecked(default_settings.supercarrier) self.registerField("supercarrier", supercarrier) no_player_navy = QtWidgets.QCheckBox() self.registerField("no_player_navy", no_player_navy) no_enemy_navy = QtWidgets.QCheckBox() self.registerField("no_enemy_navy", no_enemy_navy) desired_player_mission_duration = TimeInputs( - DEFAULT_MISSION_LENGTH, minimum=30, maximum=150 + default_settings.desired_player_mission_duration, minimum=30, maximum=150 ) self.registerField( "desired_player_mission_duration", desired_player_mission_duration.spinner diff --git a/qt_ui/windows/settings/QSettingsWindow.py b/qt_ui/windows/settings/QSettingsWindow.py index 7a488ac5..8975c781 100644 --- a/qt_ui/windows/settings/QSettingsWindow.py +++ b/qt_ui/windows/settings/QSettingsWindow.py @@ -95,7 +95,7 @@ class AutoSettingsLayout(QGridLayout): self.settings = settings self.write_full_settings = write_full_settings - for row, (name, description) in enumerate(Settings.fields(page, section)): + for row, (name, description) in enumerate(Settings.fields_for(page, section)): self.add_label(row, description) if isinstance(description, BooleanOption): self.add_checkbox_for(row, name, description)