Persist some campaign creation options.

We should also persist mod options, but those will go in a separate file
because they aren't a part of Settings.

Plugins need some work before that can be saved here. They're not
configurable in the NGW currently, so that needs to be fixed first. It
also appears that it may not be safe to inject the settings object with
plugin options until the game is created, but that needs more
investigation (see the comment in Settings.save_player_settings).

Another obvious candidate would be the desired player mission duration,
but we need to implement custom serialization for that first.
This commit is contained in:
Dan Albert 2023-04-24 22:05:28 -07:00
parent 1c20bc3966
commit e2c6d6788c
11 changed files with 116 additions and 13 deletions

View File

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

View File

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

View File

@ -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,
)
},

View File

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

View File

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

View File

@ -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),
)
},

View File

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

View File

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

View File

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

View File

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

View File

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