More campaign loader cleanup.

This commit is contained in:
Dan Albert 2021-08-14 12:11:25 -07:00
parent 103675e5bb
commit d2e22ef8bf
8 changed files with 164 additions and 156 deletions

View File

@ -0,0 +1 @@
from .campaign import Campaign

View File

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

View File

@ -20,7 +20,7 @@ from game.positioned import Positioned
from game.profiling import logged_duration from game.profiling import logged_duration
from game.scenery_group import SceneryGroup from game.scenery_group import SceneryGroup
from game.utils import Distance, meters, Heading from game.utils import Distance, meters, Heading
from .controlpoint import ( from game.theater.controlpoint import (
Airfield, Airfield,
Carrier, Carrier,
ControlPoint, ControlPoint,
@ -30,7 +30,7 @@ from .controlpoint import (
) )
if TYPE_CHECKING: if TYPE_CHECKING:
from .conflicttheater import ConflictTheater from game.theater.conflicttheater import ConflictTheater
class MizCampaignLoader: class MizCampaignLoader:

View File

@ -23,13 +23,11 @@ from .controlpoint import (
ControlPoint, ControlPoint,
MissionTarget, MissionTarget,
) )
from .mizcampaignloader import MizCampaignLoader
from .seasonalconditions import SeasonalConditions
from .frontline import FrontLine from .frontline import FrontLine
from .landmap import Landmap, load_landmap, poly_contains from .landmap import Landmap, load_landmap, poly_contains
from .latlon import LatLon from .latlon import LatLon
from .projections import TransverseMercator from .projections import TransverseMercator
from ..profiling import logged_duration from .seasonalconditions import SeasonalConditions
if TYPE_CHECKING: if TYPE_CHECKING:
from . import TheaterGroundObject from . import TheaterGroundObject
@ -243,30 +241,6 @@ class ConflictTheater:
return i return i
raise KeyError(f"Cannot find ControlPoint with ID {id}") 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 @property
def seasonal_conditions(self) -> SeasonalConditions: def seasonal_conditions(self) -> SeasonalConditions:
raise NotImplementedError raise NotImplementedError

View File

@ -54,17 +54,6 @@ from ..settings import Settings
GroundObjectTemplates = Dict[str, Dict[str, Any]] 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) @dataclass(frozen=True)
class GeneratorSettings: class GeneratorSettings:

View File

@ -27,7 +27,7 @@ from qt_ui import (
) )
from qt_ui.windows.GameUpdateSignal import GameUpdateSignal from qt_ui.windows.GameUpdateSignal import GameUpdateSignal
from qt_ui.windows.QLiberationWindow import QLiberationWindow 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.newgame.QNewGameWizard import DEFAULT_BUDGET
from qt_ui.windows.preferences.QLiberationFirstStartWindow import ( from qt_ui.windows.preferences.QLiberationFirstStartWindow import (
QLiberationFirstStartWindow, QLiberationFirstStartWindow,

View File

@ -1,120 +1,14 @@
from __future__ import annotations from __future__ import annotations
import json from typing import Optional
import logging
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Union, Tuple
import packaging.version
import yaml
from PySide2 import QtGui from PySide2 import QtGui
from PySide2.QtCore import QItemSelectionModel, QModelIndex, Qt from PySide2.QtCore import QItemSelectionModel, QModelIndex, Qt
from PySide2.QtGui import QStandardItem, QStandardItemModel from PySide2.QtGui import QStandardItem, QStandardItemModel
from PySide2.QtWidgets import QAbstractItemView, QListView from PySide2.QtWidgets import QAbstractItemView, QListView
import qt_ui.uiconstants as CONST import qt_ui.uiconstants as CONST
from game.theater import ConflictTheater from game.campaignloader.campaign import Campaign
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)
class QCampaignItem(QStandardItem): class QCampaignItem(QStandardItem):
@ -144,7 +38,7 @@ class QCampaignList(QListView):
self.setup_content(show_incompatible) self.setup_content(show_incompatible)
@property @property
def selected_campaign(self) -> Campaign: def selected_campaign(self) -> Optional[Campaign]:
return self.currentIndex().data(QCampaignList.CampaignRole) return self.currentIndex().data(QCampaignList.CampaignRole)
def setup_content(self, show_incompatible: bool) -> None: def setup_content(self, show_incompatible: bool) -> None:

View File

@ -10,17 +10,14 @@ from PySide2.QtWidgets import QVBoxLayout, QTextEdit, QLabel, QCheckBox
from jinja2 import Environment, FileSystemLoader, select_autoescape from jinja2 import Environment, FileSystemLoader, select_autoescape
from game import db from game import db
from game.campaignloader.campaign import Campaign
from game.settings import Settings from game.settings import Settings
from game.theater.start_generator import GameGenerator, GeneratorSettings, ModSettings from game.theater.start_generator import GameGenerator, GeneratorSettings, ModSettings
from game.factions.faction import Faction from game.factions.faction import Faction
from qt_ui.widgets.QLiberationCalendar import QLiberationCalendar from qt_ui.widgets.QLiberationCalendar import QLiberationCalendar
from qt_ui.widgets.spinsliders import TenthsSpinSlider, TimeInputs, CurrencySpinner from qt_ui.widgets.spinsliders import TenthsSpinSlider, TimeInputs, CurrencySpinner
from qt_ui.windows.AirWingConfigurationDialog import AirWingConfigurationDialog from qt_ui.windows.AirWingConfigurationDialog import AirWingConfigurationDialog
from qt_ui.windows.newgame.QCampaignList import ( from qt_ui.windows.newgame.QCampaignList import QCampaignList
Campaign,
QCampaignList,
load_campaigns,
)
jinja_env = Environment( jinja_env = Environment(
loader=FileSystemLoader("resources/ui/templates"), loader=FileSystemLoader("resources/ui/templates"),
@ -41,7 +38,7 @@ class NewGameWizard(QtWidgets.QWizard):
def __init__(self, parent=None): def __init__(self, parent=None):
super(NewGameWizard, self).__init__(parent) 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.faction_selection_page = FactionSelection()
self.addPage(IntroPage()) self.addPage(IntroPage())
@ -369,6 +366,11 @@ class TheaterConfiguration(QtWidgets.QWizardPage):
) )
campaign = campaignList.selected_campaign campaign = campaignList.selected_campaign
self.setField("selectedCampaign", 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.campaignMapDescription.setText(template.render({"campaign": campaign}))
self.faction_selection.setDefaultFactions(campaign) self.faction_selection.setDefaultFactions(campaign)
self.performanceText.setText( self.performanceText.setText(