diff --git a/changelog.md b/changelog.md index ba320026..2ca1571a 100644 --- a/changelog.md +++ b/changelog.md @@ -8,6 +8,7 @@ * **[Squadrons]** Ability to define a livery-set for each squadron from which Retribution will randomly choose during mission generation * **[Modding]** Updated support for F/A-18E/F/G mod version 2.2.5 * **[Campaign Setup]** Allow adjustments to naval TGOs (except carriers) on turn 0 +* **[Campaign Design]** Ability to configure specific carrier names & types in campaign's yaml file ## Fixes * **[UI/UX]** A-10A flights can be edited again. diff --git a/game/campaignloader/campaign.py b/game/campaignloader/campaign.py index 5c957a23..508690c7 100644 --- a/game/campaignloader/campaign.py +++ b/game/campaignloader/campaign.py @@ -19,6 +19,7 @@ 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 .campaigncarrierconfig import CampaignCarrierConfig from .campaigngroundconfig import TgoConfig from .mizcampaignloader import MizCampaignLoader from ..factions import FACTIONS, Faction @@ -164,6 +165,13 @@ class Campaign: return CampaignAirWingConfig({}) return CampaignAirWingConfig.from_campaign_data(squadron_data, theater) + def load_carrier_config(self) -> CampaignCarrierConfig: + try: + carrier_data = self.data["carriers"] + except KeyError: + return CampaignCarrierConfig({}) + return CampaignCarrierConfig.from_campaign_data(carrier_data) + def load_ground_forces_config(self) -> TgoConfig: ground_forces = self.data.get("ground_forces", {}) if not ground_forces: diff --git a/game/campaignloader/campaigncarrierconfig.py b/game/campaignloader/campaigncarrierconfig.py new file mode 100644 index 00000000..7060d2f6 --- /dev/null +++ b/game/campaignloader/campaigncarrierconfig.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import logging +from collections import defaultdict +from dataclasses import dataclass +from typing import Any, TYPE_CHECKING + +from game.dcs.shipunittype import ShipUnitType + +if TYPE_CHECKING: + pass + + +@dataclass(frozen=True) +class CarrierConfig: + preferred_name: str + preferred_type: ShipUnitType + + @classmethod + def from_data(cls, data: dict[str, Any]) -> CarrierConfig: + return CarrierConfig( + str(data["preferred_name"]), ShipUnitType.named(data["preferred_type"]) + ) + + +@dataclass(frozen=True) +class CampaignCarrierConfig: + by_original_name: dict[str, CarrierConfig] + + @classmethod + def from_campaign_data(cls, data: dict[str, Any]) -> CampaignCarrierConfig: + by_original_name: dict[str, CarrierConfig] = defaultdict() + for original_name, carrier_config_data in data.items(): + try: + carrier_config = CarrierConfig.from_data(carrier_config_data) + by_original_name[original_name] = carrier_config + except KeyError: + logging.warning( + f"Skipping invalid carrier config for '{original_name}'" + ) + + return CampaignCarrierConfig(by_original_name) diff --git a/game/factions/faction.py b/game/factions/faction.py index 8997ed96..d93596f3 100644 --- a/game/factions/faction.py +++ b/game/factions/faction.py @@ -2,6 +2,7 @@ from __future__ import annotations import itertools import logging +from collections import defaultdict from dataclasses import dataclass, field from functools import cached_property from typing import Optional, Dict, Type, List, Any, Iterator, TYPE_CHECKING, Set @@ -93,11 +94,8 @@ class Faction: # Required mods or asset packs requirements: Dict[str, str] = field(default_factory=dict) - # Possible carrier names - carrier_names: Set[str] = field(default_factory=set) - - # Possible helicopter carrier names - helicopter_carrier_names: Set[str] = field(default_factory=set) + # Possible carrier units mapped to names + carriers: Dict[ShipUnitType, Set[str]] = field(default_factory=dict) # Available Naval Units naval_units: Set[ShipUnitType] = field(default_factory=set) @@ -241,8 +239,30 @@ class Faction: faction.requirements = json.get("requirements", {}) - faction.carrier_names = json.get("carrier_names", []) - faction.helicopter_carrier_names = json.get("helicopter_carrier_names", []) + # First try to load the carriers in the new format which + # specifies different names for different carrier types + loaded_carriers = load_carriers(json) + + carriers: List[ShipUnitType] = [ + unit + for unit in faction.naval_units + if unit.unit_class + in [ + UnitClass.AIRCRAFT_CARRIER, + UnitClass.HELICOPTER_CARRIER, + ] + ] + carrier_names = json.get("carrier_names", []) + helicopter_carrier_names = json.get("helicopter_carrier_names", []) + for c in carriers: + if c.variant_id not in loaded_carriers: + if c.unit_class == UnitClass.AIRCRAFT_CARRIER: + loaded_carriers[c] = carrier_names + elif c.unit_class == UnitClass.HELICOPTER_CARRIER: + loaded_carriers[c] = helicopter_carrier_names + + faction.carriers = loaded_carriers + faction.naval_units.union(faction.carriers.keys()) faction.has_jtac = json.get("has_jtac", False) jtac_name = json.get("jtac_unit", None) @@ -596,3 +616,14 @@ def load_all_ships(data: list[str]) -> List[Type[ShipType]]: if item is not None: items.append(item) return items + + +def load_carriers(json: Dict[str, Any]) -> Dict[ShipUnitType, Set[str]]: + # Load carriers + items: Dict[ShipUnitType, Set[str]] = defaultdict(Set[str]) + carriers = json.get("carriers", {}) + for carrier_shiptype, shipnames in carriers.items(): + shiptype = ShipUnitType.named(carrier_shiptype) + if shiptype is not None: + items[shiptype] = shipnames + return items diff --git a/game/migrator.py b/game/migrator.py index a929c717..3b67cd94 100644 --- a/game/migrator.py +++ b/game/migrator.py @@ -208,12 +208,6 @@ class Migrator: c.faction.air_defense_units = set(c.faction.air_defense_units) if isinstance(c.faction.missiles, list): c.faction.missiles = set(c.faction.missiles) - if isinstance(c.faction.carrier_names, list): - c.faction.carrier_names = set(c.faction.carrier_names) - if isinstance(c.faction.helicopter_carrier_names, list): - c.faction.helicopter_carrier_names = set( - c.faction.helicopter_carrier_names - ) if isinstance(c.faction.naval_units, list): c.faction.naval_units = set(c.faction.naval_units) if isinstance(c.faction.building_set, list): diff --git a/game/theater/controlpoint.py b/game/theater/controlpoint.py index cc22f5f6..988dac7b 100644 --- a/game/theater/controlpoint.py +++ b/game/theater/controlpoint.py @@ -1395,6 +1395,11 @@ class NavalControlPoint( L02, L52, L61, + CV_1143_5, + CVN_71, + CVN_72, + CVN_73, + CVN_75, ]: return True return False diff --git a/game/theater/start_generator.py b/game/theater/start_generator.py index 94d7b2ea..86f76ef9 100644 --- a/game/theater/start_generator.py +++ b/game/theater/start_generator.py @@ -7,12 +7,13 @@ from datetime import datetime, time from typing import List, Optional import dcs.statics +from dcs.countries import country_dict from game import Game from game.factions.faction import Faction from game.naming import namegen from game.scenery_group import SceneryGroup -from game.theater import PointWithHeading, PresetLocation +from game.theater import PointWithHeading, PresetLocation, NavalControlPoint from game.theater.theatergroundobject import ( BuildingGroundObject, IadsBuildingGroundObject, @@ -30,8 +31,11 @@ from .theatergroup import IadsGroundGroup, IadsRole, SceneryUnit, TheaterGroup from ..armedforces.armedforces import ArmedForces from ..armedforces.forcegroup import ForceGroup from ..campaignloader.campaignairwingconfig import CampaignAirWingConfig +from ..campaignloader.campaigncarrierconfig import CampaignCarrierConfig from ..campaignloader.campaigngroundconfig import TgoConfig from ..data.groups import GroupTask +from ..data.units import UnitClass +from ..dcs.shipunittype import ShipUnitType from ..profiling import logged_duration from ..settings import Settings @@ -49,6 +53,7 @@ class GeneratorSettings: no_player_navy: bool no_enemy_navy: bool tgo_config: TgoConfig + carrier_config: CampaignCarrierConfig squadrons_start_full: bool @@ -127,11 +132,13 @@ class GameGenerator: def should_remove_carrier(self, player: bool) -> bool: faction = self.player if player else self.enemy - return self.generator_settings.no_carrier or not faction.carrier_names + return self.generator_settings.no_carrier or not faction.carriers def should_remove_lha(self, player: bool) -> bool: faction = self.player if player else self.enemy - return self.generator_settings.no_lha or not faction.helicopter_carrier_names + return self.generator_settings.no_lha or not [ + x for x in faction.carriers if x.unit_class == UnitClass.HELICOPTER_CARRIER + ] def prepare_theater(self) -> None: to_remove: List[ControlPoint] = [] @@ -223,14 +230,52 @@ class GenericCarrierGroundObjectGenerator(ControlPointGroundObjectGenerator): carrier = next(self.control_point.ground_objects[-1].units) carrier.name = carrier_name + def apply_carrier_config(self) -> None: + assert isinstance(self.control_point, NavalControlPoint) + # If the campaign designer has specified a preferred name, use that + # Note that the preferred name needs to exist in the faction, so we + # don't end up with Kuznetsov carriers called CV-59 Forrestal + preferred_name = None + preferred_type = None + carrier_map = self.generator_settings.carrier_config.by_original_name + if ccfg := carrier_map.get(self.control_point.name): + preferred_name = ccfg.preferred_name + preferred_type = ccfg.preferred_type + carrier_unit = self.control_point.ground_objects[0].groups[0].units[0] + if preferred_type and preferred_type.dcs_unit_type in [ + v + for k, v in country_dict[self.faction.country.id].Ship.__dict__.items() # type: ignore + if "__" not in k + ]: + carrier_unit.type = preferred_type.dcs_unit_type + if preferred_name: + self.control_point.name = preferred_name + else: + carrier_type = preferred_type if preferred_type else carrier_unit.unit_type + assert isinstance(carrier_type, ShipUnitType) + # Otherwise pick randomly from the names specified for that particular carrier type + carrier_names = self.faction.carriers.get(carrier_type) + if carrier_names: + self.control_point.name = random.choice(list(carrier_names)) + else: + self.control_point.name = carrier_type.display_name + carrier_unit.name = self.control_point.name + # Prevents duplicate carrier or LHA names in campaigns with more that one of either. + for carrier_type_key in self.faction.carriers: + for carrier_name in self.faction.carriers[carrier_type_key]: + if carrier_name == self.control_point.name: + self.faction.carriers[carrier_type_key].remove( + self.control_point.name + ) + class CarrierGroundObjectGenerator(GenericCarrierGroundObjectGenerator): def generate(self) -> bool: if not super().generate(): return False - carrier_names = self.faction.carrier_names - if not carrier_names: + carriers = self.faction.carriers + if not carriers: logging.info( f"Skipping generation of {self.control_point.name} because " f"{self.faction_name} has no carriers" @@ -241,6 +286,7 @@ class CarrierGroundObjectGenerator(GenericCarrierGroundObjectGenerator): if not unit_group: logging.error(f"{self.faction_name} has no access to AircraftCarrier") return False + self.generate_ground_object_from_group( unit_group, PresetLocation( @@ -250,7 +296,7 @@ class CarrierGroundObjectGenerator(GenericCarrierGroundObjectGenerator): ), GroupTask.AIRCRAFT_CARRIER, ) - self.update_carrier_name(random.choice(list(carrier_names))) + self.apply_carrier_config() return True @@ -259,8 +305,8 @@ class LhaGroundObjectGenerator(GenericCarrierGroundObjectGenerator): if not super().generate(): return False - lha_names = self.faction.helicopter_carrier_names - if not lha_names: + lhas = self.faction.carriers + if not lhas: logging.info( f"Skipping generation of {self.control_point.name} because " f"{self.faction_name} has no LHAs" @@ -282,7 +328,7 @@ class LhaGroundObjectGenerator(GenericCarrierGroundObjectGenerator): ), GroupTask.HELICOPTER_CARRIER, ) - self.update_carrier_name(random.choice(list(lha_names))) + self.apply_carrier_config() return True diff --git a/qt_ui/main.py b/qt_ui/main.py index ee512c24..fb24e21f 100644 --- a/qt_ui/main.py +++ b/qt_ui/main.py @@ -318,6 +318,7 @@ def create_game( no_player_navy=False, no_enemy_navy=False, tgo_config=campaign.load_ground_forces_config(), + carrier_config=campaign.load_carrier_config(), ), ModSettings( a4_skyhawk=False, diff --git a/qt_ui/windows/newgame/QNewGameWizard.py b/qt_ui/windows/newgame/QNewGameWizard.py index a399897b..72f05bb6 100644 --- a/qt_ui/windows/newgame/QNewGameWizard.py +++ b/qt_ui/windows/newgame/QNewGameWizard.py @@ -84,6 +84,7 @@ class NewGameWizard(QtWidgets.QWizard): no_player_navy=self.field("no_player_navy"), no_enemy_navy=self.field("no_enemy_navy"), tgo_config=campaign.load_ground_forces_config(), + carrier_config=campaign.load_carrier_config(), squadrons_start_full=self.field("squadrons_start_full"), ) mod_settings = ModSettings( diff --git a/resources/units/ships/CVN_71.yaml b/resources/units/ships/CVN_71.yaml new file mode 100644 index 00000000..fecc02b4 --- /dev/null +++ b/resources/units/ships/CVN_71.yaml @@ -0,0 +1,4 @@ +class: AircraftCarrier +price: 0 +variants: + CVN-71 Theodore Roosevelt: null \ No newline at end of file diff --git a/resources/units/ships/CVN_72.yaml b/resources/units/ships/CVN_72.yaml new file mode 100644 index 00000000..d454172e --- /dev/null +++ b/resources/units/ships/CVN_72.yaml @@ -0,0 +1,4 @@ +class: AircraftCarrier +price: 0 +variants: + CVN-72 Abraham Lincoln: null \ No newline at end of file diff --git a/resources/units/ships/CVN_73.yaml b/resources/units/ships/CVN_73.yaml new file mode 100644 index 00000000..5c1dfc35 --- /dev/null +++ b/resources/units/ships/CVN_73.yaml @@ -0,0 +1,4 @@ +class: AircraftCarrier +price: 0 +variants: + CVN-73 George Washington: null \ No newline at end of file diff --git a/resources/units/ships/CVN_75.yaml b/resources/units/ships/CVN_75.yaml new file mode 100644 index 00000000..2099d9dc --- /dev/null +++ b/resources/units/ships/CVN_75.yaml @@ -0,0 +1,4 @@ +class: AircraftCarrier +price: 0 +variants: + CVN-75 Harry S. Truman: null \ No newline at end of file diff --git a/resources/units/ships/CV_1143_5.yaml b/resources/units/ships/CV_1143_5.yaml new file mode 100644 index 00000000..eb5b7b31 --- /dev/null +++ b/resources/units/ships/CV_1143_5.yaml @@ -0,0 +1,4 @@ +class: AircraftCarrier +price: 0 +variants: + CV 1143.5 Admiral Kuznetsov (2017): null \ No newline at end of file diff --git a/tests/test_factions.py b/tests/test_factions.py index ec43dbe9..7619131d 100644 --- a/tests/test_factions.py +++ b/tests/test_factions.py @@ -92,13 +92,13 @@ class TestFactionLoader(unittest.TestCase): self.assertIn(Infantry.Soldier_M249, faction.infantry_units) self.assertIn(Stennis.name, faction.naval_units) + self.assertIn(Stennis, faction.carriers.keys()) self.assertIn(LHA_Tarawa.name, faction.naval_units) self.assertIn("mod", faction.requirements.keys()) self.assertIn("Some mod is required", faction.requirements.values()) - self.assertEqual(4, len(faction.carrier_names)) - self.assertEqual(5, len(faction.helicopter_carrier_names)) + self.assertEqual(4, len(faction.carriers)) @pytest.mark.skip(reason="Faction unit names in the json files are outdated") def test_load_valid_faction_with_invalid_country(self) -> None: