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