From d2e22ef8bfd5083888852ec0dd8d64443cae7256 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Sat, 14 Aug 2021 12:11:25 -0700 Subject: [PATCH] More campaign loader cleanup. --- game/campaignloader/__init__.py | 1 + game/campaignloader/campaign.py | 148 ++++++++++++++++++ .../mizcampaignloader.py | 4 +- game/theater/conflicttheater.py | 28 +--- game/theater/start_generator.py | 11 -- qt_ui/main.py | 2 +- qt_ui/windows/newgame/QCampaignList.py | 112 +------------ qt_ui/windows/newgame/QNewGameWizard.py | 14 +- 8 files changed, 164 insertions(+), 156 deletions(-) create mode 100644 game/campaignloader/__init__.py create mode 100644 game/campaignloader/campaign.py rename game/{theater => campaignloader}/mizcampaignloader.py (99%) diff --git a/game/campaignloader/__init__.py b/game/campaignloader/__init__.py new file mode 100644 index 00000000..ed7204dc --- /dev/null +++ b/game/campaignloader/__init__.py @@ -0,0 +1 @@ +from .campaign import Campaign diff --git a/game/campaignloader/campaign.py b/game/campaignloader/campaign.py new file mode 100644 index 00000000..ab13e61c --- /dev/null +++ b/game/campaignloader/campaign.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +import json +import logging +from collections import Iterator +from dataclasses import dataclass +from pathlib import Path +from typing import Tuple, Dict, Any + +from packaging.version import Version +import yaml + +from game.campaignloader.mizcampaignloader import MizCampaignLoader +from game.profiling import logged_duration +from game.theater import ( + ConflictTheater, + CaucasusTheater, + NevadaTheater, + PersianGulfTheater, + NormandyTheater, + TheChannelTheater, + SyriaTheater, + MarianaIslandsTheater, +) +from game.version import CAMPAIGN_FORMAT_VERSION + + +PERF_FRIENDLY = 0 +PERF_MEDIUM = 1 +PERF_HARD = 2 +PERF_NASA = 3 + + +@dataclass(frozen=True) +class Campaign: + name: str + icon_name: str + authors: str + description: str + + #: The revision of the campaign format the campaign was built for. We do not attempt + #: to migrate old campaigns, but this is used to show a warning in the UI when + #: selecting a campaign that is not up to date. + version: Tuple[int, int] + + recommended_player_faction: str + recommended_enemy_faction: str + performance: int + data: Dict[str, Any] + path: Path + + @classmethod + def from_file(cls, path: Path) -> Campaign: + with path.open() as campaign_file: + if path.suffix == ".yaml": + data = yaml.safe_load(campaign_file) + else: + data = json.load(campaign_file) + + sanitized_theater = data["theater"].replace(" ", "") + version_field = data.get("version", "0") + try: + version = Version(version_field) + except TypeError: + logging.warning( + f"Non-string campaign version in {path}. Parse may be incorrect." + ) + version = Version(str(version_field)) + return cls( + data["name"], + f"Terrain_{sanitized_theater}", + data.get("authors", "???"), + data.get("description", ""), + (version.major, version.minor), + data.get("recommended_player_faction", "USA 2005"), + data.get("recommended_enemy_faction", "Russia 1990"), + data.get("performance", 0), + data, + path, + ) + + def load_theater(self) -> ConflictTheater: + theaters = { + "Caucasus": CaucasusTheater, + "Nevada": NevadaTheater, + "Persian Gulf": PersianGulfTheater, + "Normandy": NormandyTheater, + "The Channel": TheChannelTheater, + "Syria": SyriaTheater, + "MarianaIslands": MarianaIslandsTheater, + } + theater = theaters[self.data["theater"]] + t = theater() + + try: + miz = self.data["miz"] + except KeyError as ex: + raise RuntimeError( + "Old format (non-miz) campaigns are no longer supported." + ) from ex + + with logged_duration("Importing miz data"): + MizCampaignLoader(self.path.parent / miz, t).populate_theater() + return t + + @property + def is_out_of_date(self) -> bool: + """Returns True if this campaign is not up to date with the latest format. + + This is more permissive than is_from_future, which is sensitive to minor version + bumps (the old game definitely doesn't support the minor features added in the + new version, and the campaign may require them. However, the minor version only + indicates *optional* new features, so we do not need to mark out of date + campaigns as incompatible if they are within the same major version. + """ + return self.version[0] < CAMPAIGN_FORMAT_VERSION[0] + + @property + def is_from_future(self) -> bool: + """Returns True if this campaign is newer than the supported format.""" + return self.version > CAMPAIGN_FORMAT_VERSION + + @property + def is_compatible(self) -> bool: + """Returns True is this campaign was built for this version of the game.""" + if self.version == (0, 0): + return False + if self.is_out_of_date: + return False + if self.is_from_future: + return False + return True + + @staticmethod + def iter_campaign_defs() -> Iterator[Path]: + campaign_dir = Path("resources/campaigns") + yield from campaign_dir.glob("*.json") + yield from campaign_dir.glob("*.yaml") + + @classmethod + def load_each(cls) -> Iterator[Campaign]: + for path in cls.iter_campaign_defs(): + try: + logging.debug(f"Loading campaign from {path}...") + campaign = Campaign.from_file(path) + yield campaign + except RuntimeError: + logging.exception(f"Unable to load campaign from {path}") diff --git a/game/theater/mizcampaignloader.py b/game/campaignloader/mizcampaignloader.py similarity index 99% rename from game/theater/mizcampaignloader.py rename to game/campaignloader/mizcampaignloader.py index a2565029..ab99e265 100644 --- a/game/theater/mizcampaignloader.py +++ b/game/campaignloader/mizcampaignloader.py @@ -20,7 +20,7 @@ from game.positioned import Positioned from game.profiling import logged_duration from game.scenery_group import SceneryGroup from game.utils import Distance, meters, Heading -from .controlpoint import ( +from game.theater.controlpoint import ( Airfield, Carrier, ControlPoint, @@ -30,7 +30,7 @@ from .controlpoint import ( ) if TYPE_CHECKING: - from .conflicttheater import ConflictTheater + from game.theater.conflicttheater import ConflictTheater class MizCampaignLoader: diff --git a/game/theater/conflicttheater.py b/game/theater/conflicttheater.py index dc2d3e53..4ac622cf 100644 --- a/game/theater/conflicttheater.py +++ b/game/theater/conflicttheater.py @@ -23,13 +23,11 @@ from .controlpoint import ( ControlPoint, MissionTarget, ) -from .mizcampaignloader import MizCampaignLoader -from .seasonalconditions import SeasonalConditions from .frontline import FrontLine from .landmap import Landmap, load_landmap, poly_contains from .latlon import LatLon from .projections import TransverseMercator -from ..profiling import logged_duration +from .seasonalconditions import SeasonalConditions if TYPE_CHECKING: from . import TheaterGroundObject @@ -243,30 +241,6 @@ class ConflictTheater: return i raise KeyError(f"Cannot find ControlPoint with ID {id}") - @staticmethod - def from_file_data(directory: Path, data: Dict[str, Any]) -> ConflictTheater: - theaters = { - "Caucasus": CaucasusTheater, - "Nevada": NevadaTheater, - "Persian Gulf": PersianGulfTheater, - "Normandy": NormandyTheater, - "The Channel": TheChannelTheater, - "Syria": SyriaTheater, - "MarianaIslands": MarianaIslandsTheater, - } - theater = theaters[data["theater"]] - t = theater() - - miz = data.get("miz", None) - if miz is None: - raise RuntimeError( - "Old format (non-miz) campaigns are no longer supported." - ) - - with logged_duration("Importing miz data"): - MizCampaignLoader(directory / miz, t).populate_theater() - return t - @property def seasonal_conditions(self) -> SeasonalConditions: raise NotImplementedError diff --git a/game/theater/start_generator.py b/game/theater/start_generator.py index 61cc25af..4ad78ec3 100644 --- a/game/theater/start_generator.py +++ b/game/theater/start_generator.py @@ -54,17 +54,6 @@ from ..settings import Settings GroundObjectTemplates = Dict[str, Dict[str, Any]] -UNIT_VARIETY = 6 -UNIT_AMOUNT_FACTOR = 16 -UNIT_COUNT_IMPORTANCE_LOG = 1.3 - -COUNT_BY_TASK = { - PinpointStrike: 12, - CAP: 8, - CAS: 4, - AirDefence: 1, -} - @dataclass(frozen=True) class GeneratorSettings: diff --git a/qt_ui/main.py b/qt_ui/main.py index 67609d11..e7aad522 100644 --- a/qt_ui/main.py +++ b/qt_ui/main.py @@ -27,7 +27,7 @@ from qt_ui import ( ) from qt_ui.windows.GameUpdateSignal import GameUpdateSignal from qt_ui.windows.QLiberationWindow import QLiberationWindow -from qt_ui.windows.newgame.QCampaignList import Campaign +from game.campaignloader.campaign import Campaign from qt_ui.windows.newgame.QNewGameWizard import DEFAULT_BUDGET from qt_ui.windows.preferences.QLiberationFirstStartWindow import ( QLiberationFirstStartWindow, diff --git a/qt_ui/windows/newgame/QCampaignList.py b/qt_ui/windows/newgame/QCampaignList.py index 1832baa4..a5fcf305 100644 --- a/qt_ui/windows/newgame/QCampaignList.py +++ b/qt_ui/windows/newgame/QCampaignList.py @@ -1,120 +1,14 @@ from __future__ import annotations -import json -import logging -from dataclasses import dataclass -from pathlib import Path -from typing import Any, Dict, List, Union, Tuple +from typing import Optional -import packaging.version -import yaml from PySide2 import QtGui from PySide2.QtCore import QItemSelectionModel, QModelIndex, Qt from PySide2.QtGui import QStandardItem, QStandardItemModel from PySide2.QtWidgets import QAbstractItemView, QListView import qt_ui.uiconstants as CONST -from game.theater import ConflictTheater -from game.version import CAMPAIGN_FORMAT_VERSION - -PERF_FRIENDLY = 0 -PERF_MEDIUM = 1 -PERF_HARD = 2 -PERF_NASA = 3 - - -@dataclass(frozen=True) -class Campaign: - name: str - icon_name: str - authors: str - description: str - - #: The revision of the campaign format the campaign was built for. We do not attempt - #: to migrate old campaigns, but this is used to show a warning in the UI when - #: selecting a campaign that is not up to date. - version: Tuple[int, int] - - recommended_player_faction: str - recommended_enemy_faction: str - performance: Union[PERF_FRIENDLY, PERF_MEDIUM, PERF_HARD, PERF_NASA] - data: Dict[str, Any] - path: Path - - @classmethod - def from_file(cls, path: Path) -> Campaign: - with path.open(encoding="utf-8") as campaign_file: - if path.suffix == ".yaml": - data = yaml.safe_load(campaign_file) - else: - data = json.load(campaign_file) - - sanitized_theater = data["theater"].replace(" ", "") - version_field = data.get("version", "0") - try: - version = packaging.version.parse(version_field) - except TypeError: - logging.warning( - f"Non-string campaign version in {path}. Parse may be incorrect." - ) - version = packaging.version.parse(str(version_field)) - return cls( - data["name"], - f"Terrain_{sanitized_theater}", - data.get("authors", "???"), - data.get("description", ""), - (version.major, version.minor), - data.get("recommended_player_faction", "USA 2005"), - data.get("recommended_enemy_faction", "Russia 1990"), - data.get("performance", 0), - data, - path, - ) - - def load_theater(self) -> ConflictTheater: - return ConflictTheater.from_file_data(self.path.parent, self.data) - - @property - def is_out_of_date(self) -> bool: - """Returns True if this campaign is not up to date with the latest format. - - This is more permissive than is_from_future, which is sensitive to minor version - bumps (the old game definitely doesn't support the minor features added in the - new version, and the campaign may require them. However, the minor version only - indicates *optional* new features, so we do not need to mark out of date - campaigns as incompatible if they are within the same major version. - """ - return self.version[0] < CAMPAIGN_FORMAT_VERSION[0] - - @property - def is_from_future(self) -> bool: - """Returns True if this campaign is newer than the supported format.""" - return self.version > CAMPAIGN_FORMAT_VERSION - - @property - def is_compatible(self) -> bool: - """Returns True is this campaign was built for this version of the game.""" - if not self.version: - return False - if self.is_out_of_date: - return False - if self.is_from_future: - return False - return True - - -def load_campaigns() -> List[Campaign]: - campaign_dir = Path("resources/campaigns") - campaigns = [] - for path in campaign_dir.glob("*.json"): - try: - logging.debug(f"Loading campaign from {path}...") - campaign = Campaign.from_file(path) - campaigns.append(campaign) - except RuntimeError: - logging.exception(f"Unable to load campaign from {path}") - - return sorted(campaigns, key=lambda x: x.name) +from game.campaignloader.campaign import Campaign class QCampaignItem(QStandardItem): @@ -144,7 +38,7 @@ class QCampaignList(QListView): self.setup_content(show_incompatible) @property - def selected_campaign(self) -> Campaign: + def selected_campaign(self) -> Optional[Campaign]: return self.currentIndex().data(QCampaignList.CampaignRole) def setup_content(self, show_incompatible: bool) -> None: diff --git a/qt_ui/windows/newgame/QNewGameWizard.py b/qt_ui/windows/newgame/QNewGameWizard.py index b29a4806..0bbd0715 100644 --- a/qt_ui/windows/newgame/QNewGameWizard.py +++ b/qt_ui/windows/newgame/QNewGameWizard.py @@ -10,17 +10,14 @@ from PySide2.QtWidgets import QVBoxLayout, QTextEdit, QLabel, QCheckBox from jinja2 import Environment, FileSystemLoader, select_autoescape from game import db +from game.campaignloader.campaign import Campaign from game.settings import Settings from game.theater.start_generator import GameGenerator, GeneratorSettings, ModSettings from game.factions.faction import Faction from qt_ui.widgets.QLiberationCalendar import QLiberationCalendar from qt_ui.widgets.spinsliders import TenthsSpinSlider, TimeInputs, CurrencySpinner from qt_ui.windows.AirWingConfigurationDialog import AirWingConfigurationDialog -from qt_ui.windows.newgame.QCampaignList import ( - Campaign, - QCampaignList, - load_campaigns, -) +from qt_ui.windows.newgame.QCampaignList import QCampaignList jinja_env = Environment( loader=FileSystemLoader("resources/ui/templates"), @@ -41,7 +38,7 @@ class NewGameWizard(QtWidgets.QWizard): def __init__(self, parent=None): super(NewGameWizard, self).__init__(parent) - self.campaigns = load_campaigns() + self.campaigns = list(sorted(Campaign.load_each(), key=lambda x: x.name)) self.faction_selection_page = FactionSelection() self.addPage(IntroPage()) @@ -369,6 +366,11 @@ class TheaterConfiguration(QtWidgets.QWizardPage): ) campaign = campaignList.selected_campaign self.setField("selectedCampaign", campaign) + if campaign is None: + self.campaignMapDescription.setText("No campaign selected") + self.performanceText.setText("No campaign selected") + return + self.campaignMapDescription.setText(template.render({"campaign": campaign})) self.faction_selection.setDefaultFactions(campaign) self.performanceText.setText(