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

@ -13,6 +13,7 @@ Saves from 6.x are not compatible with 7.0.
* **[Modding]** Updated Community A-4E-C mod version support to 2.1.0 release. * **[Modding]** Updated Community A-4E-C mod version support to 2.1.0 release.
* **[Modding]** Add support for VSN F-4B and F-4C mod. * **[Modding]** Add support for VSN F-4B and F-4C mod.
* **[Modding]** Custom factions can now be defined in YAML as well as JSON. JSON support may be removed in the future if having both formats causes confusion. * **[Modding]** Custom factions can now be defined in YAML as well as JSON. JSON support may be removed in the future if having both formats causes confusion.
* **[Modding]** Campaigns which require custom factions can now define those factions directly in the campaign YAML. See Operation Aliied Sword for an example.
## Fixes ## Fixes

View File

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

View File

@ -169,4 +169,7 @@ VERSION = _build_version_string()
#: #:
#: Version 10.5 #: Version 10.5
#: * Support for scenery objectives defined by quad zones. #: * 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)

View File

@ -83,9 +83,11 @@ class NewGameWizard(QtWidgets.QWizard):
def __init__(self, parent=None): def __init__(self, parent=None):
super(NewGameWizard, self).__init__(parent) super(NewGameWizard, self).__init__(parent)
factions = Factions.load()
self.campaigns = list(sorted(Campaign.load_each(), key=lambda x: x.name)) self.campaigns = list(sorted(Campaign.load_each(), key=lambda x: x.name))
self.faction_selection_page = FactionSelection() self.faction_selection_page = FactionSelection(factions)
self.addPage(IntroPage()) self.addPage(IntroPage())
self.theater_page = TheaterConfiguration( self.theater_page = TheaterConfiguration(
self.campaigns, self.faction_selection_page self.campaigns, self.faction_selection_page
@ -222,10 +224,10 @@ class IntroPage(QtWidgets.QWizardPage):
class FactionSelection(QtWidgets.QWizardPage): class FactionSelection(QtWidgets.QWizardPage):
def __init__(self, parent=None): def __init__(self, factions: Factions, parent=None) -> None:
super(FactionSelection, self).__init__(parent) super().__init__(parent)
self.factions = Factions.load() self.factions = factions
self.setTitle("Faction selection") self.setTitle("Faction selection")
self.setSubTitle( self.setSubTitle(
@ -308,15 +310,15 @@ class FactionSelection(QtWidgets.QWizardPage):
self.blueFactionSelect.clear() self.blueFactionSelect.clear()
self.redFactionSelect.clear() self.redFactionSelect.clear()
for f in self.factions.iter_faction_names(): self.factions.reset_campaign_defined()
self.blueFactionSelect.addItem(f) campaign.register_campaign_specific_factions(self.factions)
for i, r in enumerate(self.factions.iter_faction_names()): for name in self.factions.iter_faction_names():
self.redFactionSelect.addItem(r) self.blueFactionSelect.addItem(name)
if r == campaign.recommended_enemy_faction: self.redFactionSelect.addItem(name)
self.redFactionSelect.setCurrentIndex(i)
if r == campaign.recommended_player_faction: self.blueFactionSelect.setCurrentText(campaign.recommended_player_faction.name)
self.blueFactionSelect.setCurrentIndex(i) self.redFactionSelect.setCurrentText(campaign.recommended_enemy_faction.name)
self.updateUnitRecap() self.updateUnitRecap()

View File

@ -2,10 +2,181 @@
name: Syria - Operation Allied Sword name: Syria - Operation Allied Sword
theater: Syria theater: Syria
authors: Fuzzle authors: Fuzzle
recommended_player_faction: Israel-USN 2005 (Allied Sword) recommended_player_faction:
recommended_enemy_faction: Syria-Lebanon 2005 (Allied Sword) country: Combined Joint Task Forces Blue
description: <p>In this fictional scenario, a US/Israeli coalition must push north from the Israeli border, through Syria and Lebanon to Aleppo.</p><p><strong>Backstory:</strong> A Syrian-Lebanese joint force (with Russian materiel support) has attacked Israel, attmepting to cross the northern border. With the arrival of a US carrier group, Israel prepares its counterattack. The US Navy will handle the Beirut region's coastal arena, while the IAF will push through Damascus and the inland mountain ranges.</p> name: Israel-USN 2005
version: "10.1" authors: Fuzzle
description:
<p>A joint US Navy/Israeli modern faction for use with the Operation Allied
Sword scenario.</p>
locales:
- en_US
aircrafts:
- F-4E Phantom II
- F-15C Eagle
- F-15E Strike Eagle
- F-16CM Fighting Falcon (Block 50)
- F-14B Tomcat
- F/A-18C Hornet (Lot 20)
- AV-8B Harrier II Night Attack
- AH-1W SuperCobra
- AH-64D Apache Longbow
- AH-64D Apache Longbow (AI)
- S-3B Viking
- SH-60B Seahawk
- UH-1H Iroquois
- UH-60L
awacs:
- E-2C Hawkeye
tankers:
- KC-130
- S-3B Tanker
frontline_units:
- M113
- M1043 HMMWV (M2 HMG)
- M1045 HMMWV (BGM-71 TOW)
- Merkava Mk IV
- M163 Vulcan Air Defense System
artillery_units:
- M109A6 Paladin
- M270 Multiple Launch Rocket System
logistics_units:
- Truck M818 6x6
infantry_units:
- Infantry M4
- Infantry M249
- MANPADS Stinger
preset_groups:
- Hawk
- Patriot
naval_units:
- FFG Oliver Hazard Perry
- DDG Arleigh Burke IIa
- CG Ticonderoga
- LHA-1 Tarawa
- CVN-74 John C. Stennis
missiles: []
air_defense_units:
- SAM Hawk SR (AN/MPQ-50)
- M163 Vulcan Air Defense System
- M48 Chaparral
requirements: {}
carrier_names:
- CVN-71 Theodore Roosevelt
- CVN-72 Abraham Lincoln
- CVN-73 George Washington
- CVN-74 John C. Stennis
- CVN-75 Harry S. Truman
helicopter_carrier_names:
- LHA-1 Tarawa
- LHA-2 Saipan
- LHA-3 Belleau Wood
- LHA-4 Nassau
- LHA-5 Peleliu
has_jtac: true
jtac_unit: MQ-9 Reaper
doctrine: modern
liveries_overrides:
F-14B Tomcat:
- VF-142 Ghostriders
F/A-18C Hornet (Lot 20):
- VMFA-251 high visibility
AV-8B Harrier II Night Attack:
- VMAT-542
AH-1W SuperCobra:
- Marines
UH-1H Iroquois:
- US NAVY
UH-60L:
- Israeli Air Force
unrestricted_satnav: true
recommended_enemy_faction:
country: Combined Joint Task Forces Red
name: Syria-Lebanon 2005 (Allied Sword)
authors: Fuzzle
description:
<p>Syria-Lebanon alliance in a modern setting with several imported Russian
assets. Designed for use with the Allied Sword scenario.</p>
aircrafts:
- MiG-23ML Flogger-G
- MiG-25RBT Foxbat-B
- MiG-29A Fulcrum-A
- Su-17M4 Fitter-K
- Su-24M Fencer-D
- Su-30 Flanker-C
- Su-34 Fullback
- L-39ZA Albatros
- Tu-22M3 Backfire-C
- Mi-24V Hind-E
- Mi-8MTV2 Hip
- SA 342M Gazelle
- SA 342L Gazelle
- IL-76MD
awacs:
- A-50
tankers:
- IL-78M
frontline_units:
- BMP-1
- BMP-2
- BTR-80
- BRDM-2
- MT-LB
- T-55A
- T-72B with Kontakt-1 ERA
- T-90A
- ZSU-57-2 'Sparka'
artillery_units:
- BM-21 Grad
- 2S1 Gvozdika
logistics_units:
- Truck Ural-375
- LUV UAZ-469 Jeep
infantry_units:
- Paratrooper AKS
- Infantry AK-74 Rus
- Paratrooper RPG-16
- MANPADS SA-18 Igla-S "Grouse"
preset_groups:
- SA-2/S-75
- SA-3/S-125
- SA-6
- SA-11
- SA-10/S-300PS
- Silkworm
- Cold-War-Flak
- Russian Navy
naval_units:
- Corvette 1124.4 Grish
- Corvette 1241.1 Molniya
- FAC La Combattante IIa
- Frigate 1135M Rezky
air_defense_units:
- SAM P19 "Flat Face" SR (SA-2/3)
- EWR 1L13
- EWR 55G6
- SAM SA-8 Osa "Gecko" TEL
- SA-9 Strela
- SA-13 Gopher (9K35 Strela-10M3)
- SA-19 Grison (2K22 Tunguska)
- ZSU-57-2 'Sparka'
- AAA ZU-23 Closed Emplacement
- ZU-23 on Ural-375
- ZSU-23-4 Shilka
missiles:
- SSM SS-1C Scud-B
helicopter_carrier_names: []
requirements: {}
carrier_names: []
description:
<p>In this fictional scenario, a US/Israeli coalition must push north from the
Israeli border, through Syria and Lebanon to
Aleppo.</p><p><strong>Backstory:</strong> A Syrian-Lebanese joint force (with
Russian materiel support) has attacked Israel, attmepting to cross the
northern border. With the arrival of a US carrier group, Israel prepares its
counterattack. The US Navy will handle the Beirut region's coastal arena,
while the IAF will push through Damascus and the inland mountain ranges.</p>
version: "10.6"
miz: operation_allied_sword.miz miz: operation_allied_sword.miz
performance: 2 performance: 2
recommended_start_date: 2004-07-17 recommended_start_date: 2004-07-17
@ -217,4 +388,4 @@ squadrons:
- primary: Transport - primary: Transport
secondary: air-to-ground secondary: air-to-ground
aircraft: aircraft:
- Mi-8MTV2 Hip - Mi-8MTV2 Hip

View File

@ -1,88 +0,0 @@
---
country: Combined Joint Task Forces Blue
name: Israel-USN 2005 (Allied Sword)
authors: Fuzzle
description:
<p>A joint US Navy/Israeli modern faction for use with the Operation
Allied Sword scenario.</p>
locales:
- en_US
aircrafts:
- F-4E Phantom II
- F-15C Eagle
- F-15E Strike Eagle
- F-16CM Fighting Falcon (Block 50)
- F-14B Tomcat
- F/A-18C Hornet (Lot 20)
- AV-8B Harrier II Night Attack
- AH-1W SuperCobra
- AH-64D Apache Longbow
- AH-64D Apache Longbow (AI)
- S-3B Viking
- SH-60B Seahawk
- UH-1H Iroquois
- UH-60L
awacs:
- E-2C Hawkeye
tankers:
- KC-130
- S-3B Tanker
frontline_units:
- M113
- M1043 HMMWV (M2 HMG)
- M1045 HMMWV (BGM-71 TOW)
- Merkava Mk IV
- M163 Vulcan Air Defense System
artillery_units:
- M109A6 Paladin
- M270 Multiple Launch Rocket System
logistics_units:
- Truck M818 6x6
infantry_units:
- Infantry M4
- Infantry M249
- MANPADS Stinger
preset_groups:
- Hawk
- Patriot
naval_units:
- FFG Oliver Hazard Perry
- DDG Arleigh Burke IIa
- CG Ticonderoga
- LHA-1 Tarawa
- CVN-74 John C. Stennis
missiles: []
air_defense_units:
- SAM Hawk SR (AN/MPQ-50)
- M163 Vulcan Air Defense System
- M48 Chaparral
requirements: {}
carrier_names:
- CVN-71 Theodore Roosevelt
- CVN-72 Abraham Lincoln
- CVN-73 George Washington
- CVN-74 John C. Stennis
- CVN-75 Harry S. Truman
helicopter_carrier_names:
- LHA-1 Tarawa
- LHA-2 Saipan
- LHA-3 Belleau Wood
- LHA-4 Nassau
- LHA-5 Peleliu
has_jtac: true
jtac_unit: MQ-9 Reaper
doctrine: modern
liveries_overrides:
F-14B Tomcat:
- VF-142 Ghostriders
F/A-18C Hornet (Lot 20):
- VMFA-251 high visibility
AV-8B Harrier II Night Attack:
- VMAT-542
AH-1W SuperCobra:
- Marines
UH-1H Iroquois:
- US NAVY
UH-60L:
- Israeli Air Force
unrestricted_satnav: true

View File

@ -1,78 +0,0 @@
---
country: Combined Joint Task Forces Red
name: Syria-Lebanon 2005 (Allied Sword)
authors: Fuzzle
description:
<p>Syria-Lebanon alliance in a modern setting with several imported Russian
assets. Designed for use with the Allied Sword scenario.</p>
aircrafts:
- MiG-23ML Flogger-G
- MiG-25RBT Foxbat-B
- MiG-29A Fulcrum-A
- Su-17M4 Fitter-K
- Su-24M Fencer-D
- Su-30 Flanker-C
- Su-34 Fullback
- L-39ZA Albatros
- Tu-22M3 Backfire-C
- Mi-24V Hind-E
- Mi-8MTV2 Hip
- SA 342M Gazelle
- SA 342L Gazelle
- IL-76MD
awacs:
- A-50
tankers:
- IL-78M
frontline_units:
- BMP-1
- BMP-2
- BTR-80
- BRDM-2
- MT-LB
- T-55A
- T-72B with Kontakt-1 ERA
- T-90A
- ZSU-57-2 'Sparka'
artillery_units:
- BM-21 Grad
- 2S1 Gvozdika
logistics_units:
- Truck Ural-375
- LUV UAZ-469 Jeep
infantry_units:
- Paratrooper AKS
- Infantry AK-74 Rus
- Paratrooper RPG-16
- MANPADS SA-18 Igla-S "Grouse"
preset_groups:
- SA-2/S-75
- SA-3/S-125
- SA-6
- SA-11
- SA-10/S-300PS
- Silkworm
- Cold-War-Flak
- Russian Navy
naval_units:
- Corvette 1124.4 Grish
- Corvette 1241.1 Molniya
- FAC La Combattante IIa
- Frigate 1135M Rezky
air_defense_units:
- SAM P19 "Flat Face" SR (SA-2/3)
- EWR 1L13
- EWR 55G6
- SAM SA-8 Osa "Gecko" TEL
- SA-9 Strela
- SA-13 Gopher (9K35 Strela-10M3)
- SA-19 Grison (2K22 Tunguska)
- ZSU-57-2 'Sparka'
- AAA ZU-23 Closed Emplacement
- ZU-23 on Ural-375
- ZSU-23-4 Shilka
missiles:
- SSM SS-1C Scud-B
helicopter_carrier_names: []
requirements: {}
carrier_names: []

View File

@ -20,6 +20,6 @@ bugs.</p>
{% endif %} {% endif %}
<p><strong>Default factions:</strong></p> <p><strong>Default factions:</strong></p>
<p><span style="color:#82A466">{{campaign.recommended_player_faction}}</span> VS <span style="color:orange">&nbsp;{{campaign.recommended_enemy_faction}}</span></p> <p><span style="color:#82A466">{{campaign.recommended_player_faction.name}}</span> VS <span style="color:orange">&nbsp;{{campaign.recommended_enemy_faction.name}}</span></p>
{{ campaign.description|safe }} {{ campaign.description|safe }}

View File

@ -1,7 +1,7 @@
<strong>Auteur(s) : {{ campaign.authors }}</strong> <strong>Auteur(s) : {{ campaign.authors }}</strong>
<strong>Factions par défaut :</strong> <strong>Factions par défaut :</strong>
<span style="color:#82A466">&nbsp;{{campaign.recommended_player_faction}}</span> VS <span style="color:orange">&nbsp;{{campaign.recommended_enemy_faction}}</span> <span style="color:#82A466">&nbsp;{{campaign.recommended_player_faction.name}}</span> VS <span style="color:orange">&nbsp;{{campaign.recommended_enemy_faction.name}}</span>
<br/> <br/>
{{ campaign.description|safe }} {{ campaign.description|safe }}