From 77f1706cbbfc889c9094c102623ade3bcc76f099 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Tue, 25 Apr 2023 21:25:43 -0700 Subject: [PATCH] Extract plugins from settings. There isn't really any need for these two types to interact. The lua plugin manager effectively fully owned its properties, it just delegated all reads and writes to the settings object. Instead, break the plugin settings out into the plugin manager and preserve the manager in the Game. This will make it possible to expose plugin options in the NGW without breaking the game on cancel. --- game/commander/objectivefinder.py | 2 +- game/game.py | 10 ++- .../aircraft/flightgroupconfigurator.py | 8 ++- game/missiongenerator/flotgenerator.py | 2 +- game/missiongenerator/logisticsgenerator.py | 10 +-- game/missiongenerator/luagenerator.py | 3 +- game/missiongenerator/tgogenerator.py | 2 +- game/plugins/luaplugin.py | 42 +++++------- game/plugins/manager.py | 68 ++++++++++++------- game/settings/settings.py | 23 +------ game/theater/start_generator.py | 3 + qt_ui/widgets/combos/QFlightTypeComboBox.py | 14 ++-- .../windows/mission/flight/QFlightCreator.py | 12 ++-- .../flight/waypoints/QFlightWaypointTab.py | 5 +- qt_ui/windows/settings/QSettingsWindow.py | 4 +- qt_ui/windows/settings/plugins.py | 12 ++-- 16 files changed, 113 insertions(+), 107 deletions(-) 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))