Allow in-line definitions of campaign factions.

A lot of campaigns want to define custom factions. This allows them to
do so without us having to fill the built-in factions list with a bunch
of campaign-specific factions. It also makes custom campaigns more
portable as they don't need to also distribute the custom faction files.
This commit is contained in:
Dan Albert
2023-04-17 23:27:59 -07:00
parent dca256364a
commit 1ac36d03da
11 changed files with 288 additions and 197 deletions

View File

@@ -5,22 +5,24 @@ import logging
from collections.abc import Iterator
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, Tuple
from typing import Any, Dict, TYPE_CHECKING, Tuple
import yaml
from packaging.version import Version
from game import persistence
from game.profiling import logged_duration
from game.theater import (
ConflictTheater,
)
from game.theater import ConflictTheater
from game.theater.iadsnetwork.iadsnetwork import IadsNetwork
from game.theater.theaterloader import TheaterLoader
from game.version import CAMPAIGN_FORMAT_VERSION
from .campaignairwingconfig import CampaignAirWingConfig
from .factionrecommendation import FactionRecommendation
from .mizcampaignloader import MizCampaignLoader
if TYPE_CHECKING:
from game.factions.factions import Factions
PERF_FRIENDLY = 0
PERF_MEDIUM = 1
PERF_HARD = 2
@@ -40,8 +42,8 @@ class Campaign:
#: selecting a campaign that is not up to date.
version: Tuple[int, int]
recommended_player_faction: str
recommended_enemy_faction: str
recommended_player_faction: FactionRecommendation
recommended_enemy_faction: FactionRecommendation
recommended_start_date: datetime.date | None
recommended_start_time: datetime.time | None
@@ -60,7 +62,6 @@ class Campaign:
with path.open(encoding="utf-8") as campaign_file:
data = yaml.safe_load(campaign_file)
sanitized_theater = data["theater"].replace(" ", "")
version_field = data.get("version", "0")
try:
version = Version(version_field)
@@ -93,8 +94,12 @@ class Campaign:
data.get("authors", "???"),
data.get("description", ""),
(version.major, version.minor),
data.get("recommended_player_faction", "USA 2005"),
data.get("recommended_enemy_faction", "Russia 1990"),
FactionRecommendation.from_field(
data.get("recommended_player_faction"), player=True
),
FactionRecommendation.from_field(
data.get("recommended_enemy_faction"), player=False
),
start_date,
start_time,
data.get("recommended_player_money", DEFAULT_BUDGET),
@@ -163,6 +168,10 @@ class Campaign:
return False
return True
def register_campaign_specific_factions(self, factions: Factions) -> None:
self.recommended_player_faction.register_campaign_specific_faction(factions)
self.recommended_enemy_faction.register_campaign_specific_faction(factions)
@staticmethod
def iter_campaigns_in_dir(path: Path) -> Iterator[Path]:
yield from path.glob("*.yaml")

View File

@@ -0,0 +1,53 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any, TYPE_CHECKING
from game.factions import Faction
if TYPE_CHECKING:
from game.factions.factions import Factions
class FactionRecommendation(ABC):
def __init__(self, name: str) -> None:
self.name = name
@abstractmethod
def register_campaign_specific_faction(self, factions: Factions) -> None:
...
@abstractmethod
def get_faction(self, factions: Factions) -> Faction:
...
@staticmethod
def from_field(
data: str | dict[str, Any] | None, player: bool
) -> FactionRecommendation:
if data is None:
name = "USA 2005" if player else "Russia 1990"
return BuiltinFactionRecommendation(name)
if isinstance(data, str):
return BuiltinFactionRecommendation(data)
return CampaignDefinedFactionRecommendation(Faction.from_dict(data))
class BuiltinFactionRecommendation(FactionRecommendation):
def register_campaign_specific_faction(self, factions: Factions) -> None:
pass
def get_faction(self, factions: Factions) -> Faction:
return factions.get_by_name(self.name)
class CampaignDefinedFactionRecommendation(FactionRecommendation):
def __init__(self, faction: Faction) -> None:
super().__init__(faction.name)
self.faction = faction
def register_campaign_specific_faction(self, factions: Factions) -> None:
factions.add_campaign_defined(self.faction)
def get_faction(self, factions: Factions) -> Faction:
return self.faction

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import itertools
import json
import logging
from collections.abc import Iterator
@@ -14,12 +15,29 @@ from .faction import Faction
class Factions:
def __init__(self, factions: dict[str, Faction]) -> None:
self.factions = factions
self.campaign_defined_factions: dict[str, Faction] = {}
def get_by_name(self, name: str) -> Faction:
return self.factions[name]
try:
return self.factions[name]
except KeyError:
return self.campaign_defined_factions[name]
def iter_faction_names(self) -> Iterator[str]:
return iter(self.factions.keys())
# Campaign defined factions first so they show up at the top of the list in the
# UI.
return itertools.chain(self.campaign_defined_factions, self.factions)
def add_campaign_defined(self, faction: Faction) -> None:
if (
faction.name in self.factions
or faction.name in self.campaign_defined_factions
):
raise KeyError(f"Duplicate faction {faction.name}")
self.campaign_defined_factions[faction.name] = faction
def reset_campaign_defined(self) -> None:
self.campaign_defined_factions = {}
@staticmethod
def iter_faction_files_in(path: Path) -> Iterator[Path]:

View File

@@ -169,4 +169,7 @@ VERSION = _build_version_string()
#:
#: Version 10.5
#: * Support for scenery objectives defined by quad zones.
CAMPAIGN_FORMAT_VERSION = (10, 5)
#:
#: Version 10.6
#: * Support in-line definitions of campaign-specific factions.
CAMPAIGN_FORMAT_VERSION = (10, 6)