diff --git a/game/commander/objectivefinder.py b/game/commander/objectivefinder.py index e229de7d..702caf81 100644 --- a/game/commander/objectivefinder.py +++ b/game/commander/objectivefinder.py @@ -117,7 +117,7 @@ class ObjectiveFinder: if isinstance( ground_object, IadsBuildingGroundObject - ) and not self.game.settings.plugin_option("skynetiads"): + ) and not self.game.lua_plugin_manager.is_plugin_enabled("skynetiads"): # Prevent strike targets on IADS Buildings when skynet features # are disabled as they do not serve any purpose continue diff --git a/game/game.py b/game/game.py index bee156d6..0b055834 100644 --- a/game/game.py +++ b/game/game.py @@ -97,10 +97,12 @@ class Game: start_date: datetime, start_time: time | None, settings: Settings, + lua_plugin_manager: LuaPluginManager, player_budget: float, enemy_budget: float, ) -> None: self.settings = settings + self.lua_plugin_manager = lua_plugin_manager self.theater = theater self.turn = 0 # NB: This is the *start* date. It is never updated. @@ -227,7 +229,13 @@ class Game: # We need to persist this state so that names generated after game load don't # conflict with those generated before exit. naming.namegen = self.name_generator - LuaPluginManager.load_settings(self.settings) + + # The installed plugins may have changed between runs. We need to load the + # current configuration and patch in the options that were previously set. + new_plugin_manager = LuaPluginManager.load() + new_plugin_manager.update_with(self.lua_plugin_manager) + self.lua_plugin_manager = new_plugin_manager + ObjectiveDistanceCache.set_theater(self.theater) self.compute_unculled_zones(GameUpdateEvents()) if not game_still_initializing: diff --git a/game/missiongenerator/aircraft/flightgroupconfigurator.py b/game/missiongenerator/aircraft/flightgroupconfigurator.py index df242977..5b579ade 100644 --- a/game/missiongenerator/aircraft/flightgroupconfigurator.py +++ b/game/missiongenerator/aircraft/flightgroupconfigurator.py @@ -79,14 +79,18 @@ class FlightGroupConfigurator: if self.flight.flight_type in [ FlightType.TRANSPORT, FlightType.AIR_ASSAULT, - ] and self.game.settings.plugin_option("ctld"): + ] and self.game.lua_plugin_manager.is_plugin_enabled("ctld"): transfer = None if self.flight.flight_type == FlightType.TRANSPORT: coalition = self.game.coalition_for(player=self.flight.blue) transfer = coalition.transfers.transfer_for_flight(self.flight) self.mission_data.logistics.append( LogisticsGenerator( - self.flight, self.group, self.mission, self.game.settings, transfer + self.flight, + self.group, + self.mission, + self.game.lua_plugin_manager, + transfer, ).generate_logistics() ) diff --git a/game/missiongenerator/flotgenerator.py b/game/missiongenerator/flotgenerator.py index b92ce220..af7a97a3 100644 --- a/game/missiongenerator/flotgenerator.py +++ b/game/missiongenerator/flotgenerator.py @@ -141,7 +141,7 @@ class FlotGenerator: # If the option fc3LaserCode is enabled, force all JTAC # laser codes to 1113 to allow lasing for Su-25 Frogfoots and A-10A Warthogs. # Otherwise use 1688 for the first JTAC, 1687 for the second etc. - if self.game.settings.plugins["plugins.ctld.fc3LaserCode"]: + if self.game.lua_plugin_manager.is_option_enabled("ctld", "fc3LaserCode"): code = 1113 else: code = self.laser_code_registry.get_next_laser_code() diff --git a/game/missiongenerator/logisticsgenerator.py b/game/missiongenerator/logisticsgenerator.py index 6cfab31c..089a6bda 100644 --- a/game/missiongenerator/logisticsgenerator.py +++ b/game/missiongenerator/logisticsgenerator.py @@ -8,7 +8,7 @@ from game.ato import Flight from game.ato.flightplans.airassault import AirAssaultFlightPlan from game.ato.flightwaypointtype import FlightWaypointType from game.missiongenerator.missiondata import CargoInfo, LogisticsInfo -from game.settings.settings import Settings +from game.plugins import LuaPluginManager from game.transfers import TransferOrder ZONE_RADIUS = 300 @@ -21,14 +21,14 @@ class LogisticsGenerator: flight: Flight, group: FlyingGroup[Any], mission: Mission, - settings: Settings, + lua_plugin_manager: LuaPluginManager, transfer: Optional[TransferOrder] = None, ) -> None: self.flight = flight self.group = group self.transfer = transfer self.mission = mission - self.settings = settings + self.lua_plugin_manager = lua_plugin_manager def generate_logistics(self) -> LogisticsInfo: # Add Logisitcs info for the flight @@ -89,8 +89,8 @@ class LogisticsGenerator: for cargo_unit_type, amount in self.transfer.units.items() ] - if pickup_point is not None and self.settings.plugin_option( - "ctld.logisticunit" + if pickup_point is not None and self.lua_plugin_manager.is_option_enabled( + "ctld", "logisticunit" ): # Spawn logisticsunit at pickup zones country = self.mission.country(self.flight.country) diff --git a/game/missiongenerator/luagenerator.py b/game/missiongenerator/luagenerator.py index 87a18767..756d79d4 100644 --- a/game/missiongenerator/luagenerator.py +++ b/game/missiongenerator/luagenerator.py @@ -13,7 +13,6 @@ from dcs.triggers import TriggerStart from game.ato import FlightType from game.dcs.aircrafttype import AircraftType -from game.plugins import LuaPluginManager from game.theater import TheaterGroundObject from game.theater.iadsnetwork.iadsrole import IadsRole from game.utils import escape_string_for_lua @@ -246,7 +245,7 @@ class LuaGenerator: self.mission.triggerrules.triggers.append(trigger) def inject_plugins(self) -> None: - for plugin in LuaPluginManager.plugins(): + for plugin in self.game.lua_plugin_manager.iter_plugins(): if plugin.enabled: plugin.inject_scripts(self) plugin.inject_configuration(self) diff --git a/game/missiongenerator/tgogenerator.py b/game/missiongenerator/tgogenerator.py index acd76174..e7683f26 100644 --- a/game/missiongenerator/tgogenerator.py +++ b/game/missiongenerator/tgogenerator.py @@ -103,7 +103,7 @@ class GroundObjectGenerator: # Special handling for scenery objects self.add_trigger_zone_for_scenery(unit) if ( - self.game.settings.plugin_option("skynetiads") + self.game.lua_plugin_manager.is_plugin_enabled("skynetiads") and self.game.theater.iads_network.advanced_iads and isinstance(group, IadsGroundGroup) and group.iads_role.participate diff --git a/game/plugins/luaplugin.py b/game/plugins/luaplugin.py index 048df8d7..d5587c00 100644 --- a/game/plugins/luaplugin.py +++ b/game/plugins/luaplugin.py @@ -7,8 +7,6 @@ from dataclasses import dataclass from pathlib import Path from typing import List, Optional, TYPE_CHECKING -from game.settings import Settings - if TYPE_CHECKING: from game.missiongenerator.luagenerator import LuaGenerator @@ -50,26 +48,10 @@ class PluginSettings: def __init__(self, identifier: str, enabled_by_default: bool) -> None: self.identifier = identifier - self.enabled_by_default = enabled_by_default - self.settings = Settings() - self.initialize_settings() - - def set_settings(self, settings: Settings) -> None: - self.settings = settings - self.initialize_settings() - - def initialize_settings(self) -> None: - # Plugin options are saved in the game's Settings, but it's possible for - # plugins to change across loads. If new plugins are added or new - # options added to those plugins, initialize the new settings. - self.settings.initialize_plugin_option(self.identifier, self.enabled_by_default) - - @property - def enabled(self) -> bool: - return self.settings.plugin_option(self.identifier) + self.enabled = enabled_by_default def set_enabled(self, enabled: bool) -> None: - self.settings.set_plugin_option(self.identifier, enabled) + self.enabled = enabled class LuaPluginOption(PluginSettings): @@ -173,6 +155,12 @@ class LuaPlugin(PluginSettings): def options(self) -> List[LuaPluginOption]: return self.definition.options + def is_option_enabled(self, identifier: str) -> bool: + for option in self.options: + if option.identifier == identifier: + return option.enabled + raise KeyError(f"Plugin {self.identifier} has no option {self.identifier}") + @classmethod def from_json(cls, name: str, path: Path) -> Optional[LuaPlugin]: try: @@ -183,12 +171,6 @@ class LuaPlugin(PluginSettings): return cls(definition) - def set_settings(self, settings: Settings) -> None: - """Attaches the plugin to a settings object.""" - super().set_settings(settings) - for option in self.definition.options: - option.set_settings(self.settings) - def inject_scripts(self, lua_generator: LuaGenerator) -> None: """Injects the plugin's scripts into the mission.""" for work_order in self.definition.work_orders: @@ -231,3 +213,11 @@ class LuaPlugin(PluginSettings): for work_order in self.definition.config_work_orders: work_order.work(lua_generator) + + def update_with(self, other: LuaPlugin) -> None: + self.enabled = other.enabled + for option in self.options: + try: + option.enabled = other.is_option_enabled(option.identifier) + except KeyError: + continue diff --git a/game/plugins/manager.py b/game/plugins/manager.py index ac87f1e9..9ea225fa 100644 --- a/game/plugins/manager.py +++ b/game/plugins/manager.py @@ -1,20 +1,21 @@ +from __future__ import annotations + import json import logging +from collections.abc import Iterator from pathlib import Path -from typing import Dict, List -from game.settings import Settings from .luaplugin import LuaPlugin class LuaPluginManager: """Manages available and loaded lua plugins.""" - _plugins_loaded = False - _plugins: Dict[str, LuaPlugin] = {} + def __init__(self, plugins: dict[str, LuaPlugin]) -> None: + self._plugins: dict[str, LuaPlugin] = plugins - @classmethod - def _load_plugins(cls) -> None: + @staticmethod + def load() -> LuaPluginManager: plugins_path = Path("resources/plugins") path = plugins_path / "plugins.json" @@ -23,6 +24,7 @@ class LuaPluginManager: logging.info(f"Reading plugins list from {path}") + plugins = {} data = json.loads(path.read_text()) for name in data: plugin_path = plugins_path / name / "plugin.json" @@ -34,27 +36,41 @@ class LuaPluginManager: logging.info(f"Loading plugin {name} from {plugin_path}") plugin = LuaPlugin.from_json(name, plugin_path) if plugin is not None: - cls._plugins[name] = plugin - cls._plugins_loaded = True + plugins[name] = plugin + return LuaPluginManager(plugins) - @classmethod - def _get_plugins(cls) -> Dict[str, LuaPlugin]: - if not cls._plugins_loaded: - cls._load_plugins() - return cls._plugins + def update_with(self, other: LuaPluginManager) -> None: + """Updates all setting values with those in the given plugin manager. - @classmethod - def plugins(cls) -> List[LuaPlugin]: - return list(cls._get_plugins().values()) + When a game is loaded, LuaPluginManager.load() is called to load the latest set + of plugins and settings. This is called with the plugin manager that was saved + to the Game object to preserve any options that were set, and then the Game is + updated with this manager. - @classmethod - def load_settings(cls, settings: Settings) -> None: - """Attaches all loaded plugins to the given settings object. - - The LuaPluginManager singleton can only be attached to a single Settings object - at a time, and plugins will update the Settings object directly, so attaching - the plugin manager to a detached Settings object (say, during the new game - wizard, but then canceling the new game) will break the settings UI. + This needs to happen because the set of available plugins (or their options) can + change between runs. """ - for plugin in cls.plugins(): - plugin.set_settings(settings) + for plugin in self.iter_plugins(): + try: + old_plugin = other.by_id(plugin.identifier) + except KeyError: + continue + plugin.update_with(old_plugin) + + def iter_plugins(self) -> Iterator[LuaPlugin]: + yield from self._plugins.values() + + def by_id(self, identifier: str) -> LuaPlugin: + return self._plugins[identifier] + + def is_plugin_enabled(self, plugin_id: str) -> bool: + try: + return self.by_id(plugin_id).enabled + except KeyError: + return False + + def is_option_enabled(self, plugin_id: str, option_id: str) -> bool: + try: + return self.by_id(plugin_id).is_option_enabled(option_id) + except KeyError: + return False diff --git a/game/settings/settings.py b/game/settings/settings.py index ea3b9bff..bf849477 100644 --- a/game/settings/settings.py +++ b/game/settings/settings.py @@ -1,10 +1,10 @@ import logging from collections.abc import Iterator -from dataclasses import Field, dataclass, field, fields +from dataclasses import Field, dataclass, fields from datetime import timedelta from enum import Enum, unique from pathlib import Path -from typing import Any, Dict, Optional, get_type_hints +from typing import Any, Optional, get_type_hints import yaml from dcs.forcedoptions import ForcedOptions @@ -490,9 +490,6 @@ class Settings: enable_frontline_cheats: bool = False enable_base_capture_cheat: bool = False - # LUA Plugins system - plugins: Dict[str, bool] = field(default_factory=dict) - only_player_takeoff: bool = True # Legacy parameter do not use def save_player_settings(self) -> None: @@ -548,22 +545,6 @@ class Settings: """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}" - - def initialize_plugin_option(self, identifier: str, default_value: bool) -> None: - try: - self.plugin_option(identifier) - except KeyError: - self.set_plugin_option(identifier, default_value) - - def plugin_option(self, identifier: str) -> bool: - return self.plugins[self.plugin_settings_key(identifier)] - - def set_plugin_option(self, identifier: str, enabled: bool) -> None: - self.plugins[self.plugin_settings_key(identifier)] = enabled - def __setstate__(self, state: dict[str, Any]) -> None: # __setstate__ is called with the dict of the object being unpickled. We # can provide save compatibility for new settings options (which diff --git a/game/theater/start_generator.py b/game/theater/start_generator.py index e70e79c6..a10406f3 100644 --- a/game/theater/start_generator.py +++ b/game/theater/start_generator.py @@ -31,6 +31,7 @@ from ..armedforces.armedforces import ArmedForces from ..armedforces.forcegroup import ForceGroup from ..campaignloader.campaignairwingconfig import CampaignAirWingConfig from ..data.groups import GroupTask +from ..plugins import LuaPluginManager from ..profiling import logged_duration from ..settings import Settings @@ -97,6 +98,8 @@ class GameGenerator: start_date=self.generator_settings.start_date, start_time=self.generator_settings.start_time, settings=self.settings, + # TODO: Hoist into NGW so we can expose those options. + lua_plugin_manager=LuaPluginManager.load(), player_budget=self.generator_settings.player_budget, enemy_budget=self.generator_settings.enemy_budget, ) diff --git a/qt_ui/widgets/combos/QFlightTypeComboBox.py b/qt_ui/widgets/combos/QFlightTypeComboBox.py index c8ec3b90..1d82d76f 100644 --- a/qt_ui/widgets/combos/QFlightTypeComboBox.py +++ b/qt_ui/widgets/combos/QFlightTypeComboBox.py @@ -1,9 +1,9 @@ """Combo box for selecting a flight's task type.""" from PySide6.QtWidgets import QComboBox -from game.ato.flighttype import FlightType -from game.settings.settings import Settings +from game.ato.flighttype import FlightType +from game.plugins import LuaPluginManager from game.theater import ConflictTheater, MissionTarget @@ -11,14 +11,18 @@ class QFlightTypeComboBox(QComboBox): """Combo box for selecting a flight task type.""" def __init__( - self, theater: ConflictTheater, target: MissionTarget, settings: Settings + self, + theater: ConflictTheater, + target: MissionTarget, + lua_plugin_manager: LuaPluginManager, ) -> None: super().__init__() self.theater = theater self.target = target for mission_type in self.target.mission_types(for_player=True): - if mission_type == FlightType.AIR_ASSAULT and not settings.plugin_option( - "ctld" + if ( + mission_type == FlightType.AIR_ASSAULT + and not lua_plugin_manager.is_plugin_enabled("ctld") ): # Only add Air Assault if ctld plugin is enabled continue diff --git a/qt_ui/windows/mission/flight/QFlightCreator.py b/qt_ui/windows/mission/flight/QFlightCreator.py index 94e4f65c..aa657ded 100644 --- a/qt_ui/windows/mission/flight/QFlightCreator.py +++ b/qt_ui/windows/mission/flight/QFlightCreator.py @@ -4,22 +4,22 @@ from PySide6.QtCore import Qt, Signal from PySide6.QtWidgets import ( QComboBox, QDialog, + QHBoxLayout, QLabel, + QLineEdit, QMessageBox, QPushButton, QVBoxLayout, - QLineEdit, - QHBoxLayout, ) from dcs.unittype import FlyingType from game import Game +from game.ato.flight import Flight +from game.ato.flightroster import FlightRoster +from game.ato.package import Package from game.ato.starttype import StartType from game.squadrons.squadron import Squadron from game.theater import ControlPoint, OffMapSpawn -from game.ato.package import Package -from game.ato.flightroster import FlightRoster -from game.ato.flight import Flight from qt_ui.uiconstants import EVENT_ICONS from qt_ui.widgets.QFlightSizeSpinner import QFlightSizeSpinner from qt_ui.widgets.QLabeledWidget import QLabeledWidget @@ -51,7 +51,7 @@ class QFlightCreator(QDialog): layout = QVBoxLayout() self.task_selector = QFlightTypeComboBox( - self.game.theater, package.target, self.game.settings + self.game.theater, package.target, self.game.lua_plugin_manager ) self.task_selector.setCurrentIndex(0) self.task_selector.currentIndexChanged.connect(self.on_task_changed) diff --git a/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py b/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py index bd521062..14216b86 100644 --- a/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py +++ b/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py @@ -61,8 +61,9 @@ class QFlightWaypointTab(QFrame): self.recreate_buttons.clear() for task in self.package.target.mission_types(for_player=True): - if task == FlightType.AIR_ASSAULT and not self.game.settings.plugin_option( - "ctld" + if ( + task == FlightType.AIR_ASSAULT + and not self.game.lua_plugin_manager.is_plugin_enabled("ctld") ): # Only add Air Assault if ctld plugin is enabled continue diff --git a/qt_ui/windows/settings/QSettingsWindow.py b/qt_ui/windows/settings/QSettingsWindow.py index 8975c781..8ee199f0 100644 --- a/qt_ui/windows/settings/QSettingsWindow.py +++ b/qt_ui/windows/settings/QSettingsWindow.py @@ -285,7 +285,7 @@ class QSettingsWindow(QDialog): self.categoryModel.appendRow(cheat) self.right_layout.addWidget(self.cheatPage) - self.pluginsPage = PluginsPage() + self.pluginsPage = PluginsPage(self.game.lua_plugin_manager) plugins = QStandardItem("LUA Plugins") plugins.setIcon(CONST.ICONS["Plugins"]) plugins.setEditable(False) @@ -293,7 +293,7 @@ class QSettingsWindow(QDialog): self.categoryModel.appendRow(plugins) self.right_layout.addWidget(self.pluginsPage) - self.pluginsOptionsPage = PluginOptionsPage() + self.pluginsOptionsPage = PluginOptionsPage(self.game.lua_plugin_manager) pluginsOptions = QStandardItem("LUA Plugins Options") pluginsOptions.setIcon(CONST.ICONS["PluginsOptions"]) pluginsOptions.setEditable(False) diff --git a/qt_ui/windows/settings/plugins.py b/qt_ui/windows/settings/plugins.py index 5e0fb597..eef91499 100644 --- a/qt_ui/windows/settings/plugins.py +++ b/qt_ui/windows/settings/plugins.py @@ -12,14 +12,14 @@ from game.plugins import LuaPlugin, LuaPluginManager class PluginsBox(QGroupBox): - def __init__(self) -> None: + def __init__(self, manager: LuaPluginManager) -> None: super().__init__("Plugins") layout = QGridLayout() layout.setAlignment(Qt.AlignTop) self.setLayout(layout) - for row, plugin in enumerate(LuaPluginManager.plugins()): + for row, plugin in enumerate(manager.iter_plugins()): if not plugin.show_in_ui: continue @@ -32,14 +32,14 @@ class PluginsBox(QGroupBox): class PluginsPage(QWidget): - def __init__(self) -> None: + def __init__(self, manager: LuaPluginManager) -> None: super().__init__() layout = QVBoxLayout() layout.setAlignment(Qt.AlignTop) self.setLayout(layout) - layout.addWidget(PluginsBox()) + layout.addWidget(PluginsBox(manager)) class PluginOptionsBox(QGroupBox): @@ -60,13 +60,13 @@ class PluginOptionsBox(QGroupBox): class PluginOptionsPage(QWidget): - def __init__(self) -> None: + def __init__(self, manager: LuaPluginManager) -> None: super().__init__() layout = QVBoxLayout() layout.setAlignment(Qt.AlignTop) self.setLayout(layout) - for plugin in LuaPluginManager.plugins(): + for plugin in manager.iter_plugins(): if plugin.options: layout.addWidget(PluginOptionsBox(plugin))