diff --git a/.github/ISSUE_TEMPLATE/mod_support.md b/.github/ISSUE_TEMPLATE/mod_support.md new file mode 100644 index 00000000..be6b644c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/mod_support.md @@ -0,0 +1,12 @@ +--- +name: Mod support request +about: Request Liberation support for new mods, or updates to existing mods +title: Add/update +labels: mod support +assignees: '' + +--- + +* Mod name: +* Mod URL: +* Update or new mod? \ No newline at end of file diff --git a/changelog.md b/changelog.md index 593e2cbd..d20d4225 100644 --- a/changelog.md +++ b/changelog.md @@ -4,20 +4,40 @@ Saves from 4.x are not compatible with 5.0. ## Features/Improvements +* **[Campaign]** Weather! Theaters now experience weather that is more realistic for the region and its current season. For example, Persian Gulf will have very hot, sunny summers and Marianas will experience lots of rain during fall. These changes affect pressure, temperature, clouds and precipitation. Additionally, temperature will drop during the night, by an amount that is somewhat realistic for the region. * **[Campaign]** Weapon data such as fallbacks and introduction years is now moddable. Due to the new architecture to support this, the old data was not automatically migrated. * **[Campaign]** Era-restricted loadouts will now skip LGBs when no TGP is available in the loadout. This only applies to default loadouts; buddy-lasing can be coordinated with custom loadouts. * **[Campaign]** FOBs control point can have FARP/helipad slot and host helicopters. To enable this feature on a FOB, add "Invisible FARP" statics objects near the FOB location in the campaign definition file. +* **[Campaign]** (WIP) Squadrons now have a home base and will not operate out of other bases. See https://github.com/dcs-liberation/dcs_liberation/issues/1145 for status. +* **[Campaign]** Aircraft now belong to squadrons rather than bases to support squadron location transfers. * **[Campaign AI]** Overhauled campaign AI target prioritization. This currently only affects the ordering of DEAD missions. * **[Campaign AI]** Player front line stances can now be automated. Improved stance selection for AI. * **[Campaign AI]** Reworked layout of hold, join, split, and ingress points. Should result in much shorter flight plans in general while still maintaining safe join/split/hold points. * **[Campaign AI]** Auto-planning mission range limits are now specified per-aircraft. On average this means that longer range missions will now be plannable. The limit only accounts for the direct distance to the target, not the path taken. +* **[Campaign AI]** Transport aircraft will now be bought only if necessary at control points which can produce ground units and are capable to operate transport aircraft. * **[Campaign AI]** Aircraft will now only be automatically purchased or assigned at appropriate bases. Naval aircraft will default to only operating from carriers, Harriers will default to LHAs and shore bases, helicopters will operate from anywhere. This can be customized per-squadron. +* **[Mission Generation]** EWRs are now also headed towards the center of the conflict +* **[Modding]** Campaigns now specify the squadrons that are present in the campaign, their roles, and their starting bases. Players can customize this at game start but the campaign will choose the defaults. * **[Kneeboard]** Minimum required fuel estimates have been added to the kneeboard for aircraft with supporting data (currently only the Hornet). -* **[New Game Wizard]** Can now customize the player's air wing before campaign start to disable or rename squadrons. +* **[Kneeboard]** QNH (pressure MSL) and temperature have been added to the kneeboard. +* **[New Game Wizard]** Can now customize the player's air wing before campaign start to disable, relocate, or rename squadrons. +* **[UI]** Sell Button for aircraft will be disabled if there are no units available to be sold or all are already assigned to a mission ## Fixes * **[Campaign]** Naval control points will no longer claim ground objectives during campaign generation and prevent them from spawning. +* **[Mission Generation]** Mission results and other files will now be opened with enforced utf-8 encoding to prevent an issue where destroyed ground units were untracked because of special characters in their names. +* **[UI]** Selling of Units is now visible again in the UI dialog and shows the correct amount of sold units + +# 4.1.1 + +Saves from 4.1.0 are compatible with 4.1.1. + +## Fixes + +* **[Campaign]** Fixed broken support for Mariana Islands map. +* **[Mission Generation]** Fix SAM sites pointing towards the center of the conflict. +* **[Flight Planning]** No longer using Su-34 for CAP missions. # 4.1.0 @@ -30,6 +50,7 @@ Saves from 4.0.0 are compatible with 4.1.0. * **[Campaign AI]** Adjustments to aircraft selection priorities for most mission types. * **[Engine]** Support for DCS 2.7.4.9632 and newer, including the Marianas map, F-16 JSOWs, NASAMS, and Tin Shield EWR. * **[Flight Planning]** CAP patrol altitudes are now set per-aircraft. By default the altitude will be set based on the aircraft's maximum speed. +* **[Flight Planning]** CAP patrol speeds are now set per-aircraft to be more suitable/sensible. By default the speed will be set based on the aircraft's maximum speed. * **[Mission Generation]** Improvements for better support of the Skynet Plugin and long range SAMs are now acting as EWR * **[Mission Generation]** SAM sites are now headed towards the center of the conflict * **[Mods]** Support for latest version of Gripen mod. In-progress campaigns may need to re-plan Gripen flights to pick up updated loadouts. @@ -49,6 +70,7 @@ Saves from 4.0.0 are compatible with 4.1.0. * **[Data]** Fixed Introduction dates for targeting pods (ATFLIR and LITENING were both a few years too early). * **[Data]** Removed SA-10 from Syria 2011 faction. * **[Economy]** EWRs can now be bought and sold for the correct price and can no longer be used to generate money +* **[Flight Planning]** Helicopters are now correctly identified, and will fly ingress/CAS/BAI/egress and similar at low altitude. * **[Flight Planning]** Fixed potential issue with angles > 360° or < 0° being generated when summing two angles. * **[Mission Generation]** The lua data for other plugins is now generated correctly * **[Mission Generation]** Fixed problem with opfor planning missions against sold ground objects like SAMs @@ -56,6 +78,7 @@ Saves from 4.0.0 are compatible with 4.1.0. * **[Mission Generation]** Prevent the creation of a transfer order with 0 units for a rare situtation when a point was captured. * **[Mission Generation]** Planned transfers which will be impossible after a base capture will no longer prevent the mission result submit. * **[Mission Generation]** Fix occasional KeyError preventing mission generation when all units of the same type in a convoy were killed. +* **[Mission Generation]** Fix for AAA Flak generator using Opel Blitz preventing the mission from being generated because duplicate unit names were used. * **[Mission Generation]** Fixed a potential bug with laser code generation where it would generate invalid codes. * **[UI]** Statistics window tick marks are now always integers. * **[UI]** Statistics window now shows the correct info for the turn diff --git a/game/campaignloader/__init__.py b/game/campaignloader/__init__.py new file mode 100644 index 00000000..937a39b2 --- /dev/null +++ b/game/campaignloader/__init__.py @@ -0,0 +1,2 @@ +from .campaign import Campaign +from .campaignairwingconfig import CampaignAirWingConfig, SquadronConfig diff --git a/game/campaignloader/campaign.py b/game/campaignloader/campaign.py new file mode 100644 index 00000000..29cc45b7 --- /dev/null +++ b/game/campaignloader/campaign.py @@ -0,0 +1,157 @@ +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.profiling import logged_duration +from game.theater import ( + ConflictTheater, + CaucasusTheater, + NevadaTheater, + PersianGulfTheater, + NormandyTheater, + TheChannelTheater, + SyriaTheater, + MarianaIslandsTheater, +) +from game.version import CAMPAIGN_FORMAT_VERSION +from .campaignairwingconfig import CampaignAirWingConfig +from .mizcampaignloader import MizCampaignLoader + + +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 + + def load_air_wing_config(self, theater: ConflictTheater) -> CampaignAirWingConfig: + try: + squadron_data = self.data["squadrons"] + except KeyError: + logging.warning(f"Campaign {self.name} does not define any squadrons") + return CampaignAirWingConfig({}) + return CampaignAirWingConfig.from_campaign_data(squadron_data, theater) + + @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/campaignloader/campaignairwingconfig.py b/game/campaignloader/campaignairwingconfig.py new file mode 100644 index 00000000..3c7acae4 --- /dev/null +++ b/game/campaignloader/campaignairwingconfig.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import logging +from collections import defaultdict +from dataclasses import dataclass +from typing import Any, TYPE_CHECKING, Union + +from gen.flights.flight import FlightType +from game.theater.controlpoint import ControlPoint + +if TYPE_CHECKING: + from game.theater import ConflictTheater + + +@dataclass(frozen=True) +class SquadronConfig: + primary: FlightType + secondary: list[FlightType] + aircraft: list[str] + + @property + def auto_assignable(self) -> set[FlightType]: + return set(self.secondary) | {self.primary} + + @classmethod + def from_data(cls, data: dict[str, Any]) -> SquadronConfig: + secondary_raw = data.get("secondary") + if secondary_raw is None: + secondary = [] + elif isinstance(secondary_raw, str): + secondary = cls.expand_secondary_alias(secondary_raw) + else: + secondary = [FlightType(s) for s in secondary_raw] + + return SquadronConfig( + FlightType(data["primary"]), secondary, data.get("aircraft", []) + ) + + @staticmethod + def expand_secondary_alias(alias: str) -> list[FlightType]: + if alias == "any": + return list(FlightType) + elif alias == "air-to-air": + return [t for t in FlightType if t.is_air_to_air] + elif alias == "air-to-ground": + return [t for t in FlightType if t.is_air_to_ground] + raise KeyError(f"Unknown secondary mission type: {alias}") + + +@dataclass(frozen=True) +class CampaignAirWingConfig: + by_location: dict[ControlPoint, list[SquadronConfig]] + + @classmethod + def from_campaign_data( + cls, data: dict[Union[str, int], Any], theater: ConflictTheater + ) -> CampaignAirWingConfig: + by_location: dict[ControlPoint, list[SquadronConfig]] = defaultdict(list) + for base_id, squadron_configs in data.items(): + if isinstance(base_id, int): + base = theater.find_control_point_by_id(base_id) + else: + base = theater.control_point_named(base_id) + + for squadron_data in squadron_configs: + by_location[base].append(SquadronConfig.from_data(squadron_data)) + + return CampaignAirWingConfig(by_location) diff --git a/game/campaignloader/defaultsquadronassigner.py b/game/campaignloader/defaultsquadronassigner.py new file mode 100644 index 00000000..c529738a --- /dev/null +++ b/game/campaignloader/defaultsquadronassigner.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +import logging +from typing import Optional, TYPE_CHECKING + +from game.squadrons import Squadron +from game.squadrons.squadrondef import SquadronDef +from game.squadrons.squadrondefloader import SquadronDefLoader +from gen.flights.flight import FlightType +from .campaignairwingconfig import CampaignAirWingConfig, SquadronConfig +from .squadrondefgenerator import SquadronDefGenerator +from ..dcs.aircrafttype import AircraftType +from ..theater import ControlPoint + +if TYPE_CHECKING: + from game import Game + from game.coalition import Coalition + + +class DefaultSquadronAssigner: + def __init__( + self, config: CampaignAirWingConfig, game: Game, coalition: Coalition + ) -> None: + self.config = config + self.game = game + self.coalition = coalition + self.air_wing = coalition.air_wing + self.squadron_defs = SquadronDefLoader(game, coalition).load() + self.squadron_def_generator = SquadronDefGenerator(self.coalition) + + def claim_squadron_def(self, squadron: SquadronDef) -> None: + try: + self.squadron_defs[squadron.aircraft].remove(squadron) + except ValueError: + pass + + def assign(self) -> None: + for control_point, squadron_configs in self.config.by_location.items(): + if not control_point.is_friendly(self.coalition.player): + continue + for squadron_config in squadron_configs: + squadron_def = self.find_squadron_for(squadron_config, control_point) + if squadron_def is None: + logging.info( + f"{self.coalition.faction.name} has no aircraft compatible " + f"with {squadron_config.primary} at {control_point}" + ) + continue + + self.claim_squadron_def(squadron_def) + squadron = Squadron.create_from( + squadron_def, control_point, self.coalition, self.game + ) + squadron.set_auto_assignable_mission_types( + squadron_config.auto_assignable + ) + self.air_wing.add_squadron(squadron) + + def find_squadron_for( + self, config: SquadronConfig, control_point: ControlPoint + ) -> Optional[SquadronDef]: + for preferred_aircraft in config.aircraft: + squadron_def = self.find_preferred_squadron( + preferred_aircraft, config.primary, control_point + ) + if squadron_def is not None: + return squadron_def + + # If we didn't find any of the preferred types we should use any squadron + # compatible with the primary task. + squadron_def = self.find_squadron_for_task(config.primary, control_point) + if squadron_def is not None: + return squadron_def + + # If we can't find any squadron matching the requirement, we should + # create one. + return self.squadron_def_generator.generate_for_task( + config.primary, control_point + ) + + def find_preferred_squadron( + self, preferred_aircraft: str, task: FlightType, control_point: ControlPoint + ) -> Optional[SquadronDef]: + # Attempt to find a squadron with the name in the request. + squadron_def = self.find_squadron_by_name( + preferred_aircraft, task, control_point + ) + if squadron_def is not None: + return squadron_def + + # If the name didn't match a squadron available to this coalition, try to find + # an aircraft with the matching name that meets the requirements. + try: + aircraft = AircraftType.named(preferred_aircraft) + except KeyError: + # No aircraft with this name. + return None + + if aircraft not in self.coalition.faction.aircrafts: + return None + + squadron_def = self.find_squadron_for_airframe(aircraft, task, control_point) + if squadron_def is not None: + return squadron_def + + # No premade squadron available for this aircraft that meets the requirements, + # so generate one if possible. + return self.squadron_def_generator.generate_for_aircraft(aircraft) + + @staticmethod + def squadron_compatible_with( + squadron: SquadronDef, task: FlightType, control_point: ControlPoint + ) -> bool: + return squadron.operates_from(control_point) and task in squadron.mission_types + + def find_squadron_for_airframe( + self, aircraft: AircraftType, task: FlightType, control_point: ControlPoint + ) -> Optional[SquadronDef]: + for squadron in self.squadron_defs[aircraft]: + if self.squadron_compatible_with(squadron, task, control_point): + return squadron + return None + + def find_squadron_by_name( + self, name: str, task: FlightType, control_point: ControlPoint + ) -> Optional[SquadronDef]: + for squadrons in self.squadron_defs.values(): + for squadron in squadrons: + if squadron.name == name and self.squadron_compatible_with( + squadron, task, control_point + ): + return squadron + return None + + def find_squadron_for_task( + self, task: FlightType, control_point: ControlPoint + ) -> Optional[SquadronDef]: + for squadrons in self.squadron_defs.values(): + for squadron in squadrons: + if self.squadron_compatible_with(squadron, task, control_point): + return squadron + return None diff --git a/game/campaignloader/mizcampaignloader.py b/game/campaignloader/mizcampaignloader.py new file mode 100644 index 00000000..d217250f --- /dev/null +++ b/game/campaignloader/mizcampaignloader.py @@ -0,0 +1,466 @@ +from __future__ import annotations + +import itertools +from functools import cached_property +from pathlib import Path +from typing import Iterator, List, Dict, Tuple, TYPE_CHECKING + +from dcs import Mission +from dcs.countries import CombinedJointTaskForcesBlue, CombinedJointTaskForcesRed +from dcs.country import Country +from dcs.planes import F_15C +from dcs.ships import Stennis, LHA_Tarawa, HandyWind, USS_Arleigh_Burke_IIa +from dcs.statics import Fortification, Warehouse +from dcs.terrain import Airport +from dcs.unitgroup import PlaneGroup, ShipGroup, VehicleGroup, StaticGroup +from dcs.vehicles import Armor, Unarmed, MissilesSS, AirDefence + +from game.point_with_heading import PointWithHeading +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 game.theater.controlpoint import ( + Airfield, + Carrier, + ControlPoint, + Fob, + Lha, + OffMapSpawn, +) + +if TYPE_CHECKING: + from game.theater.conflicttheater import ConflictTheater + + +class MizCampaignLoader: + BLUE_COUNTRY = CombinedJointTaskForcesBlue() + RED_COUNTRY = CombinedJointTaskForcesRed() + + OFF_MAP_UNIT_TYPE = F_15C.id + + CV_UNIT_TYPE = Stennis.id + LHA_UNIT_TYPE = LHA_Tarawa.id + FRONT_LINE_UNIT_TYPE = Armor.M_113.id + SHIPPING_LANE_UNIT_TYPE = HandyWind.id + + FOB_UNIT_TYPE = Unarmed.SKP_11.id + FARP_HELIPAD = "SINGLE_HELIPAD" + + OFFSHORE_STRIKE_TARGET_UNIT_TYPE = Fortification.Oil_platform.id + SHIP_UNIT_TYPE = USS_Arleigh_Burke_IIa.id + MISSILE_SITE_UNIT_TYPE = MissilesSS.Scud_B.id + COASTAL_DEFENSE_UNIT_TYPE = MissilesSS.Hy_launcher.id + + # Multiple options for air defenses so campaign designers can more accurately see + # the coverage of their IADS for the expected type. + LONG_RANGE_SAM_UNIT_TYPES = { + AirDefence.Patriot_ln.id, + AirDefence.S_300PS_5P85C_ln.id, + AirDefence.S_300PS_5P85D_ln.id, + } + + MEDIUM_RANGE_SAM_UNIT_TYPES = { + AirDefence.Hawk_ln.id, + AirDefence.S_75M_Volhov.id, + AirDefence._5p73_s_125_ln.id, + } + + SHORT_RANGE_SAM_UNIT_TYPES = { + AirDefence.M1097_Avenger.id, + AirDefence.Rapier_fsa_launcher.id, + AirDefence._2S6_Tunguska.id, + AirDefence.Strela_1_9P31.id, + } + + AAA_UNIT_TYPES = { + AirDefence.Flak18.id, + AirDefence.Vulcan.id, + AirDefence.ZSU_23_4_Shilka.id, + } + + EWR_UNIT_TYPE = AirDefence._1L13_EWR.id + + ARMOR_GROUP_UNIT_TYPE = Armor.M_1_Abrams.id + + FACTORY_UNIT_TYPE = Fortification.Workshop_A.id + + AMMUNITION_DEPOT_UNIT_TYPE = Warehouse._Ammunition_depot.id + + STRIKE_TARGET_UNIT_TYPE = Fortification.Tech_combine.id + + def __init__(self, miz: Path, theater: ConflictTheater) -> None: + self.theater = theater + self.mission = Mission() + with logged_duration("Loading miz"): + self.mission.load_file(str(miz)) + self.control_point_id = itertools.count(1000) + + # If there are no red carriers there usually aren't red units. Make sure + # both countries are initialized so we don't have to deal with None. + if self.mission.country(self.BLUE_COUNTRY.name) is None: + self.mission.coalition["blue"].add_country(self.BLUE_COUNTRY) + if self.mission.country(self.RED_COUNTRY.name) is None: + self.mission.coalition["red"].add_country(self.RED_COUNTRY) + + @staticmethod + def control_point_from_airport(airport: Airport) -> ControlPoint: + cp = Airfield(airport) + cp.captured = airport.is_blue() + + # Use the unlimited aircraft option to determine if an airfield should + # be owned by the player when the campaign is "inverted". + cp.captured_invert = airport.unlimited_aircrafts + + return cp + + def country(self, blue: bool) -> Country: + country = self.mission.country( + self.BLUE_COUNTRY.name if blue else self.RED_COUNTRY.name + ) + # Should be guaranteed because we initialized them. + assert country + return country + + @property + def blue(self) -> Country: + return self.country(blue=True) + + @property + def red(self) -> Country: + return self.country(blue=False) + + def off_map_spawns(self, blue: bool) -> Iterator[PlaneGroup]: + for group in self.country(blue).plane_group: + if group.units[0].type == self.OFF_MAP_UNIT_TYPE: + yield group + + def carriers(self, blue: bool) -> Iterator[ShipGroup]: + for group in self.country(blue).ship_group: + if group.units[0].type == self.CV_UNIT_TYPE: + yield group + + def lhas(self, blue: bool) -> Iterator[ShipGroup]: + for group in self.country(blue).ship_group: + if group.units[0].type == self.LHA_UNIT_TYPE: + yield group + + def fobs(self, blue: bool) -> Iterator[VehicleGroup]: + for group in self.country(blue).vehicle_group: + if group.units[0].type == self.FOB_UNIT_TYPE: + yield group + + @property + def ships(self) -> Iterator[ShipGroup]: + for group in self.red.ship_group: + if group.units[0].type == self.SHIP_UNIT_TYPE: + yield group + + @property + def offshore_strike_targets(self) -> Iterator[StaticGroup]: + for group in self.red.static_group: + if group.units[0].type == self.OFFSHORE_STRIKE_TARGET_UNIT_TYPE: + yield group + + @property + def missile_sites(self) -> Iterator[VehicleGroup]: + for group in self.red.vehicle_group: + if group.units[0].type == self.MISSILE_SITE_UNIT_TYPE: + yield group + + @property + def coastal_defenses(self) -> Iterator[VehicleGroup]: + for group in self.red.vehicle_group: + if group.units[0].type == self.COASTAL_DEFENSE_UNIT_TYPE: + yield group + + @property + def long_range_sams(self) -> Iterator[VehicleGroup]: + for group in self.red.vehicle_group: + if group.units[0].type in self.LONG_RANGE_SAM_UNIT_TYPES: + yield group + + @property + def medium_range_sams(self) -> Iterator[VehicleGroup]: + for group in self.red.vehicle_group: + if group.units[0].type in self.MEDIUM_RANGE_SAM_UNIT_TYPES: + yield group + + @property + def short_range_sams(self) -> Iterator[VehicleGroup]: + for group in self.red.vehicle_group: + if group.units[0].type in self.SHORT_RANGE_SAM_UNIT_TYPES: + yield group + + @property + def aaa(self) -> Iterator[VehicleGroup]: + for group in itertools.chain(self.blue.vehicle_group, self.red.vehicle_group): + if group.units[0].type in self.AAA_UNIT_TYPES: + yield group + + @property + def ewrs(self) -> Iterator[VehicleGroup]: + for group in self.red.vehicle_group: + if group.units[0].type in self.EWR_UNIT_TYPE: + yield group + + @property + def armor_groups(self) -> Iterator[VehicleGroup]: + for group in itertools.chain(self.blue.vehicle_group, self.red.vehicle_group): + if group.units[0].type in self.ARMOR_GROUP_UNIT_TYPE: + yield group + + @property + def helipads(self) -> Iterator[StaticGroup]: + for group in self.blue.static_group: + if group.units[0].type == self.FARP_HELIPAD: + yield group + + @property + def factories(self) -> Iterator[StaticGroup]: + for group in self.blue.static_group: + if group.units[0].type in self.FACTORY_UNIT_TYPE: + yield group + + @property + def ammunition_depots(self) -> Iterator[StaticGroup]: + for group in itertools.chain(self.blue.static_group, self.red.static_group): + if group.units[0].type in self.AMMUNITION_DEPOT_UNIT_TYPE: + yield group + + @property + def strike_targets(self) -> Iterator[StaticGroup]: + for group in itertools.chain(self.blue.static_group, self.red.static_group): + if group.units[0].type in self.STRIKE_TARGET_UNIT_TYPE: + yield group + + @property + def scenery(self) -> List[SceneryGroup]: + return SceneryGroup.from_trigger_zones(self.mission.triggers._zones) + + @cached_property + def control_points(self) -> Dict[int, ControlPoint]: + control_points = {} + for airport in self.mission.terrain.airport_list(): + if airport.is_blue() or airport.is_red(): + control_point = self.control_point_from_airport(airport) + control_points[control_point.id] = control_point + + for blue in (False, True): + for group in self.off_map_spawns(blue): + control_point = OffMapSpawn( + next(self.control_point_id), str(group.name), group.position + ) + control_point.captured = blue + control_point.captured_invert = group.late_activation + control_points[control_point.id] = control_point + for ship in self.carriers(blue): + control_point = Carrier( + ship.name, ship.position, next(self.control_point_id) + ) + control_point.captured = blue + control_point.captured_invert = ship.late_activation + control_points[control_point.id] = control_point + for ship in self.lhas(blue): + control_point = Lha( + ship.name, ship.position, next(self.control_point_id) + ) + control_point.captured = blue + control_point.captured_invert = ship.late_activation + control_points[control_point.id] = control_point + for fob in self.fobs(blue): + control_point = Fob( + str(fob.name), fob.position, next(self.control_point_id) + ) + control_point.captured = blue + control_point.captured_invert = fob.late_activation + control_points[control_point.id] = control_point + + return control_points + + @property + def front_line_path_groups(self) -> Iterator[VehicleGroup]: + for group in self.country(blue=True).vehicle_group: + if group.units[0].type == self.FRONT_LINE_UNIT_TYPE: + yield group + + @property + def shipping_lane_groups(self) -> Iterator[ShipGroup]: + for group in self.country(blue=True).ship_group: + if group.units[0].type == self.SHIPPING_LANE_UNIT_TYPE: + yield group + + def add_supply_routes(self) -> None: + for group in self.front_line_path_groups: + # The unit will have its first waypoint at the source CP and the final + # waypoint at the destination CP. Each waypoint defines the path of the + # cargo ship. + waypoints = [p.position for p in group.points] + origin = self.theater.closest_control_point(waypoints[0]) + if origin is None: + raise RuntimeError( + f"No control point near the first waypoint of {group.name}" + ) + destination = self.theater.closest_control_point(waypoints[-1]) + if destination is None: + raise RuntimeError( + f"No control point near the final waypoint of {group.name}" + ) + + self.control_points[origin.id].create_convoy_route(destination, waypoints) + self.control_points[destination.id].create_convoy_route( + origin, list(reversed(waypoints)) + ) + + def add_shipping_lanes(self) -> None: + for group in self.shipping_lane_groups: + # The unit will have its first waypoint at the source CP and the final + # waypoint at the destination CP. Each waypoint defines the path of the + # cargo ship. + waypoints = [p.position for p in group.points] + origin = self.theater.closest_control_point(waypoints[0]) + if origin is None: + raise RuntimeError( + f"No control point near the first waypoint of {group.name}" + ) + destination = self.theater.closest_control_point(waypoints[-1]) + if destination is None: + raise RuntimeError( + f"No control point near the final waypoint of {group.name}" + ) + + self.control_points[origin.id].create_shipping_lane(destination, waypoints) + self.control_points[destination.id].create_shipping_lane( + origin, list(reversed(waypoints)) + ) + + def objective_info( + self, near: Positioned, allow_naval: bool = False + ) -> Tuple[ControlPoint, Distance]: + closest = self.theater.closest_control_point(near.position, allow_naval) + distance = meters(closest.position.distance_to_point(near.position)) + return closest, distance + + def add_preset_locations(self) -> None: + for static in self.offshore_strike_targets: + closest, distance = self.objective_info(static) + closest.preset_locations.offshore_strike_locations.append( + PointWithHeading.from_point( + static.position, Heading.from_degrees(static.units[0].heading) + ) + ) + + for ship in self.ships: + closest, distance = self.objective_info(ship, allow_naval=True) + closest.preset_locations.ships.append( + PointWithHeading.from_point( + ship.position, Heading.from_degrees(ship.units[0].heading) + ) + ) + + for group in self.missile_sites: + closest, distance = self.objective_info(group) + closest.preset_locations.missile_sites.append( + PointWithHeading.from_point( + group.position, Heading.from_degrees(group.units[0].heading) + ) + ) + + for group in self.coastal_defenses: + closest, distance = self.objective_info(group) + closest.preset_locations.coastal_defenses.append( + PointWithHeading.from_point( + group.position, Heading.from_degrees(group.units[0].heading) + ) + ) + + for group in self.long_range_sams: + closest, distance = self.objective_info(group) + closest.preset_locations.long_range_sams.append( + PointWithHeading.from_point( + group.position, Heading.from_degrees(group.units[0].heading) + ) + ) + + for group in self.medium_range_sams: + closest, distance = self.objective_info(group) + closest.preset_locations.medium_range_sams.append( + PointWithHeading.from_point( + group.position, Heading.from_degrees(group.units[0].heading) + ) + ) + + for group in self.short_range_sams: + closest, distance = self.objective_info(group) + closest.preset_locations.short_range_sams.append( + PointWithHeading.from_point( + group.position, Heading.from_degrees(group.units[0].heading) + ) + ) + + for group in self.aaa: + closest, distance = self.objective_info(group) + closest.preset_locations.aaa.append( + PointWithHeading.from_point( + group.position, Heading.from_degrees(group.units[0].heading) + ) + ) + + for group in self.ewrs: + closest, distance = self.objective_info(group) + closest.preset_locations.ewrs.append( + PointWithHeading.from_point( + group.position, Heading.from_degrees(group.units[0].heading) + ) + ) + + for group in self.armor_groups: + closest, distance = self.objective_info(group) + closest.preset_locations.armor_groups.append( + PointWithHeading.from_point( + group.position, Heading.from_degrees(group.units[0].heading) + ) + ) + + for static in self.helipads: + closest, distance = self.objective_info(static) + closest.helipads.append( + PointWithHeading.from_point( + static.position, Heading.from_degrees(static.units[0].heading) + ) + ) + + for static in self.factories: + closest, distance = self.objective_info(static) + closest.preset_locations.factories.append( + PointWithHeading.from_point( + static.position, Heading.from_degrees(static.units[0].heading) + ) + ) + + for static in self.ammunition_depots: + closest, distance = self.objective_info(static) + closest.preset_locations.ammunition_depots.append( + PointWithHeading.from_point( + static.position, Heading.from_degrees(static.units[0].heading) + ) + ) + + for static in self.strike_targets: + closest, distance = self.objective_info(static) + closest.preset_locations.strike_locations.append( + PointWithHeading.from_point( + static.position, Heading.from_degrees(static.units[0].heading) + ) + ) + + for scenery_group in self.scenery: + closest, distance = self.objective_info(scenery_group) + closest.preset_locations.scenery.append(scenery_group) + + def populate_theater(self) -> None: + for control_point in self.control_points.values(): + self.theater.add_controlpoint(control_point) + self.add_preset_locations() + self.add_supply_routes() + self.add_shipping_lanes() diff --git a/game/campaignloader/squadrondefgenerator.py b/game/campaignloader/squadrondefgenerator.py new file mode 100644 index 00000000..f8b90a1a --- /dev/null +++ b/game/campaignloader/squadrondefgenerator.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import itertools +import random +from typing import TYPE_CHECKING, Optional + +from game.dcs.aircrafttype import AircraftType +from game.squadrons.operatingbases import OperatingBases +from game.squadrons.squadrondef import SquadronDef +from game.theater import ControlPoint +from gen.flights.ai_flight_planner_db import aircraft_for_task, tasks_for_aircraft +from gen.flights.flight import FlightType + +if TYPE_CHECKING: + from game.coalition import Coalition + + +class SquadronDefGenerator: + def __init__(self, coalition: Coalition) -> None: + self.coalition = coalition + self.count = itertools.count(1) + self.used_nicknames: set[str] = set() + + def generate_for_task( + self, task: FlightType, control_point: ControlPoint + ) -> Optional[SquadronDef]: + aircraft_choice: Optional[AircraftType] = None + for aircraft in aircraft_for_task(task): + if aircraft not in self.coalition.faction.aircrafts: + continue + if not control_point.can_operate(aircraft): + continue + aircraft_choice = aircraft + # 50/50 chance to keep looking for an aircraft that isn't as far up the + # priority list to maintain some unit variety. + if random.choice([True, False]): + break + + if aircraft_choice is None: + return None + return self.generate_for_aircraft(aircraft_choice) + + def generate_for_aircraft(self, aircraft: AircraftType) -> SquadronDef: + return SquadronDef( + name=f"Squadron {next(self.count):03}", + nickname=self.random_nickname(), + country=self.coalition.country_name, + role="Flying Squadron", + aircraft=aircraft, + livery=None, + mission_types=tuple(tasks_for_aircraft(aircraft)), + operating_bases=OperatingBases.default_for_aircraft(aircraft), + pilot_pool=[], + ) + + @staticmethod + def _make_random_nickname() -> str: + from gen.naming import ANIMALS + + animal = random.choice(ANIMALS) + adjective = random.choice( + ( + None, + "Red", + "Blue", + "Green", + "Golden", + "Black", + "Fighting", + "Flying", + ) + ) + if adjective is None: + return animal.title() + return f"{adjective} {animal}".title() + + def random_nickname(self) -> str: + while True: + nickname = self._make_random_nickname() + if nickname not in self.used_nicknames: + self.used_nicknames.add(nickname) + return nickname diff --git a/game/coalition.py b/game/coalition.py index b6e681f9..e15d916d 100644 --- a/game/coalition.py +++ b/game/coalition.py @@ -5,14 +5,15 @@ from typing import TYPE_CHECKING, Any, Optional from dcs import Point from faker import Faker +from game.campaignloader import CampaignAirWingConfig +from game.campaignloader.defaultsquadronassigner import DefaultSquadronAssigner from game.commander import TheaterCommander from game.commander.missionscheduler import MissionScheduler from game.income import Income -from game.inventory import GlobalAircraftInventory from game.navmesh import NavMesh from game.orderedset import OrderedSet from game.profiling import logged_duration, MultiEventTracer -from game.savecompat import has_save_compat_for +from game.squadrons import AirWing from game.threatzones import ThreatZones from game.transfers import PendingTransfers @@ -21,10 +22,9 @@ if TYPE_CHECKING: from game.data.doctrine import Doctrine from game.factions.faction import Faction from game.procurement import AircraftProcurementRequest, ProcurementAi -from game.squadrons import AirWing from game.theater.bullseye import Bullseye from game.theater.transitnetwork import TransitNetwork, TransitNetworkBuilder -from gen import AirTaskingOrder +from gen.ato import AirTaskingOrder class Coalition: @@ -40,7 +40,7 @@ class Coalition: self.procurement_requests: OrderedSet[AircraftProcurementRequest] = OrderedSet() self.bullseye = Bullseye(Point(0, 0)) self.faker = Faker(self.faction.locales) - self.air_wing = AirWing(game, self) + self.air_wing = AirWing(game) self.transfers = PendingTransfers(game, player) # Late initialized because the two coalitions in the game are mutually @@ -87,10 +87,6 @@ class Coalition: assert self._navmesh is not None return self._navmesh - @property - def aircraft_inventory(self) -> GlobalAircraftInventory: - return self.game.aircraft_inventory - def __getstate__(self) -> dict[str, Any]: state = self.__dict__.copy() # Avoid persisting any volatile types that can be deterministically @@ -100,14 +96,7 @@ class Coalition: del state["faker"] return state - @has_save_compat_for(5) def __setstate__(self, state: dict[str, Any]) -> None: - # Begin save compat - old_procurement_requests = state["procurement_requests"] - if isinstance(old_procurement_requests, list): - state["procurement_requests"] = OrderedSet(old_procurement_requests) - # End save compat - self.__dict__.update(state) # Regenerate any state that was not persisted. self.on_load() @@ -120,6 +109,11 @@ class Coalition: raise RuntimeError("Double-initialization of Coalition.opponent") self._opponent = opponent + def configure_default_air_wing( + self, air_wing_config: CampaignAirWingConfig + ) -> None: + DefaultSquadronAssigner(air_wing_config, self.game, self).assign() + def adjust_budget(self, amount: float) -> None: self.budget += amount @@ -197,7 +191,9 @@ class Coalition: return for cp in self.game.theater.control_points_for(self.player): - cp.pending_unit_deliveries.refund_all(self) + cp.ground_unit_orders.refund_all(self) + for squadron in self.air_wing.iter_squadrons(): + squadron.refund_orders() def plan_missions(self) -> None: color = "Blue" if self.player else "Red" diff --git a/game/commander/aircraftallocator.py b/game/commander/aircraftallocator.py index a50dbd22..0339ff27 100644 --- a/game/commander/aircraftallocator.py +++ b/game/commander/aircraftallocator.py @@ -1,8 +1,8 @@ from typing import Optional, Tuple from game.commander.missionproposals import ProposedFlight -from game.inventory import GlobalAircraftInventory -from game.squadrons import AirWing, Squadron +from game.squadrons.airwing import AirWing +from game.squadrons.squadron import Squadron from game.theater import ControlPoint, MissionTarget from game.utils import meters from gen.flights.ai_flight_planner_db import aircraft_for_task @@ -14,15 +14,10 @@ class AircraftAllocator: """Finds suitable aircraft for proposed missions.""" def __init__( - self, - air_wing: AirWing, - closest_airfields: ClosestAirfields, - global_inventory: GlobalAircraftInventory, - is_player: bool, + self, air_wing: AirWing, closest_airfields: ClosestAirfields, is_player: bool ) -> None: self.air_wing = air_wing self.closest_airfields = closest_airfields - self.global_inventory = global_inventory self.is_player = is_player def find_squadron_for_flight( @@ -55,24 +50,20 @@ class AircraftAllocator: for airfield in self.closest_airfields.operational_airfields: if not airfield.is_friendly(self.is_player): continue - inventory = self.global_inventory.for_control_point(airfield) for aircraft in types: if not airfield.can_operate(aircraft): continue - if inventory.available(aircraft) < flight.num_aircraft: - continue distance_to_target = meters(target.distance_to(airfield)) if distance_to_target > aircraft.max_mission_range: continue # Valid location with enough aircraft available. Find a squadron to fit # the role. squadrons = self.air_wing.auto_assignable_for_task_with_type( - aircraft, task + aircraft, task, airfield ) for squadron in squadrons: - if squadron.operates_from(airfield) and squadron.can_provide_pilots( + if squadron.operates_from(airfield) and squadron.can_fulfill_flight( flight.num_aircraft ): - inventory.remove_aircraft(aircraft, flight.num_aircraft) return airfield, squadron return None diff --git a/game/commander/objectivefinder.py b/game/commander/objectivefinder.py index cf5c6102..e684b98e 100644 --- a/game/commander/objectivefinder.py +++ b/game/commander/objectivefinder.py @@ -157,7 +157,10 @@ class ObjectiveFinder: for control_point in self.enemy_control_points(): if not isinstance(control_point, Airfield): continue - if control_point.base.total_aircraft >= min_aircraft: + if ( + control_point.allocated_aircraft(self.game).total_present + >= min_aircraft + ): airfields.append(control_point) return self._targets_by_range(airfields) diff --git a/game/commander/packagebuilder.py b/game/commander/packagebuilder.py index da96a8e2..a4baf9c7 100644 --- a/game/commander/packagebuilder.py +++ b/game/commander/packagebuilder.py @@ -1,13 +1,12 @@ from typing import Optional +from game.commander.aircraftallocator import AircraftAllocator from game.commander.missionproposals import ProposedFlight from game.dcs.aircrafttype import AircraftType -from game.inventory import GlobalAircraftInventory -from game.squadrons import AirWing +from game.squadrons.airwing import AirWing from game.theater import MissionTarget, OffMapSpawn, ControlPoint from game.utils import nautical_miles -from gen import Package -from game.commander.aircraftallocator import AircraftAllocator +from gen.ato import Package from gen.flights.closestairfields import ClosestAirfields from gen.flights.flight import Flight @@ -19,7 +18,6 @@ class PackageBuilder: self, location: MissionTarget, closest_airfields: ClosestAirfields, - global_inventory: GlobalAircraftInventory, air_wing: AirWing, is_player: bool, package_country: str, @@ -30,10 +28,7 @@ class PackageBuilder: self.is_player = is_player self.package_country = package_country self.package = Package(location, auto_asap=asap) - self.allocator = AircraftAllocator( - air_wing, closest_airfields, global_inventory, is_player - ) - self.global_inventory = global_inventory + self.allocator = AircraftAllocator(air_wing, closest_airfields, is_player) self.start_type = start_type def plan_flight(self, plan: ProposedFlight) -> bool: @@ -93,6 +88,5 @@ class PackageBuilder: """Returns any planned flights to the inventory.""" flights = list(self.package.flights) for flight in flights: - self.global_inventory.return_from_flight(flight) - flight.clear_roster() + flight.return_pilots_and_aircraft() self.package.remove_flight(flight) diff --git a/game/commander/packagefulfiller.py b/game/commander/packagefulfiller.py index 83dbcf76..a8cca58a 100644 --- a/game/commander/packagefulfiller.py +++ b/game/commander/packagefulfiller.py @@ -5,16 +5,15 @@ from collections import defaultdict from typing import Set, Iterable, Dict, TYPE_CHECKING, Optional from game.commander.missionproposals import ProposedMission, ProposedFlight, EscortType +from game.commander.packagebuilder import PackageBuilder from game.data.doctrine import Doctrine -from game.inventory import GlobalAircraftInventory from game.procurement import AircraftProcurementRequest from game.profiling import MultiEventTracer from game.settings import Settings from game.squadrons import AirWing from game.theater import ConflictTheater from game.threatzones import ThreatZones -from gen import AirTaskingOrder, Package -from game.commander.packagebuilder import PackageBuilder +from gen.ato import AirTaskingOrder, Package from gen.flights.closestairfields import ObjectiveDistanceCache from gen.flights.flight import FlightType from gen.flights.flightplan import FlightPlanBuilder @@ -27,15 +26,10 @@ class PackageFulfiller: """Responsible for package aircraft allocation and flight plan layout.""" def __init__( - self, - coalition: Coalition, - theater: ConflictTheater, - aircraft_inventory: GlobalAircraftInventory, - settings: Settings, + self, coalition: Coalition, theater: ConflictTheater, settings: Settings ) -> None: self.coalition = coalition self.theater = theater - self.aircraft_inventory = aircraft_inventory self.player_missions_asap = settings.auto_ato_player_missions_asap self.default_start_type = settings.default_start_type @@ -137,7 +131,6 @@ class PackageFulfiller: builder = PackageBuilder( mission.location, ObjectiveDistanceCache.get_closest_airfields(mission.location), - self.aircraft_inventory, self.air_wing, self.is_player, self.coalition.country_name, diff --git a/game/commander/tasks/packageplanningtask.py b/game/commander/tasks/packageplanningtask.py index cf75eb1b..a45774c3 100644 --- a/game/commander/tasks/packageplanningtask.py +++ b/game/commander/tasks/packageplanningtask.py @@ -11,12 +11,11 @@ from game.commander.missionproposals import ProposedFlight, EscortType, Proposed from game.commander.packagefulfiller import PackageFulfiller from game.commander.tasks.theatercommandertask import TheaterCommanderTask from game.commander.theaterstate import TheaterState -from game.data.doctrine import Doctrine from game.settings import AutoAtoBehavior from game.theater import MissionTarget from game.theater.theatergroundobject import IadsGroundObject, NavalGroundObject from game.utils import Distance, meters -from gen import Package +from gen.ato import Package from gen.flights.flight import FlightType if TYPE_CHECKING: @@ -54,8 +53,6 @@ class PackagePlanningTask(TheaterCommanderTask, Generic[MissionTargetT]): def execute(self, coalition: Coalition) -> None: if self.package is None: raise RuntimeError("Attempted to execute failed package planning task") - for flight in self.package.flights: - coalition.aircraft_inventory.claim_for_flight(flight) coalition.ato.add_package(self.package) @abstractmethod @@ -100,7 +97,6 @@ class PackagePlanningTask(TheaterCommanderTask, Generic[MissionTargetT]): fulfiller = PackageFulfiller( state.context.coalition, state.context.theater, - state.available_aircraft, state.context.settings, ) self.package = fulfiller.plan_mission( diff --git a/game/commander/theaterstate.py b/game/commander/theaterstate.py index 4450c95b..8732206b 100644 --- a/game/commander/theaterstate.py +++ b/game/commander/theaterstate.py @@ -10,9 +10,9 @@ from typing import TYPE_CHECKING, Any, Union, Optional from game.commander.garrisons import Garrisons from game.commander.objectivefinder import ObjectiveFinder from game.htn import WorldState -from game.inventory import GlobalAircraftInventory from game.profiling import MultiEventTracer from game.settings import Settings +from game.squadrons import AirWing from game.theater import ControlPoint, FrontLine, MissionTarget, ConflictTheater from game.theater.theatergroundobject import ( TheaterGroundObject, @@ -58,7 +58,6 @@ class TheaterState(WorldState["TheaterState"]): strike_targets: list[TheaterGroundObject[Any]] enemy_barcaps: list[ControlPoint] threat_zones: ThreatZones - available_aircraft: GlobalAircraftInventory def _rebuild_threat_zones(self) -> None: """Recreates the theater's threat zones based on the current planned state.""" @@ -122,7 +121,6 @@ class TheaterState(WorldState["TheaterState"]): strike_targets=list(self.strike_targets), enemy_barcaps=list(self.enemy_barcaps), threat_zones=self.threat_zones, - available_aircraft=self.available_aircraft.clone(), # Persistent properties are not copied. These are a way for failed subtasks # to communicate requirements to other tasks. For example, the task to # attack enemy garrisons might fail because the target area has IADS @@ -172,5 +170,4 @@ class TheaterState(WorldState["TheaterState"]): strike_targets=list(finder.strike_targets()), enemy_barcaps=list(game.theater.control_points_for(not player)), threat_zones=game.threat_zone_for(not player), - available_aircraft=game.aircraft_inventory.clone(), ) diff --git a/game/data/doctrine.py b/game/data/doctrine.py index 7ef7c59a..0466d31d 100644 --- a/game/data/doctrine.py +++ b/game/data/doctrine.py @@ -1,9 +1,7 @@ from dataclasses import dataclass from datetime import timedelta -from typing import Any from game.data.groundunitclass import GroundUnitClass -from game.savecompat import has_save_compat_for from game.utils import Distance, feet, nautical_miles @@ -79,32 +77,6 @@ class Doctrine: ground_unit_procurement_ratios: GroundUnitProcurementRatios - @has_save_compat_for(5) - def __setstate__(self, state: dict[str, Any]) -> None: - if "max_ingress_distance" not in state: - try: - state["max_ingress_distance"] = state["ingress_distance"] - del state["ingress_distance"] - except KeyError: - state["max_ingress_distance"] = state["ingress_egress_distance"] - del state["ingress_egress_distance"] - - max_ip: Distance = state["max_ingress_distance"] - if "min_ingress_distance" not in state: - if max_ip < nautical_miles(10): - min_ip = nautical_miles(5) - else: - min_ip = nautical_miles(10) - state["min_ingress_distance"] = min_ip - - self.__dict__.update(state) - - -class MissionPlannerMaxRanges: - @has_save_compat_for(5) - def __init__(self) -> None: - pass - MODERN_DOCTRINE = Doctrine( cap=True, diff --git a/game/dcs/aircrafttype.py b/game/dcs/aircrafttype.py index bebc1123..421e55d5 100644 --- a/game/dcs/aircrafttype.py +++ b/game/dcs/aircrafttype.py @@ -29,12 +29,20 @@ from game.radio.channels import ( ViggenRadioChannelAllocator, NoOpChannelAllocator, ) -from game.utils import Distance, Speed, feet, kph, knots, nautical_miles +from game.utils import ( + Distance, + SPEED_OF_SOUND_AT_SEA_LEVEL, + Speed, + feet, + kph, + knots, + nautical_miles, +) if TYPE_CHECKING: from gen.aircraft import FlightData - from gen import AirSupport, RadioFrequency, RadioRegistry - from gen.radios import Radio + from gen.airsupport import AirSupport + from gen.radios import Radio, RadioFrequency, RadioRegistry @dataclass(frozen=True) @@ -186,7 +194,7 @@ class AircraftType(UnitType[Type[FlyingType]]): @property def preferred_patrol_altitude(self) -> Distance: - if self.patrol_altitude: + if self.patrol_altitude is not None: return self.patrol_altitude else: # Estimate based on max speed. @@ -212,6 +220,40 @@ class AircraftType(UnitType[Type[FlyingType]]): min(altitude_for_highest_speed, rounded_altitude), ) + def preferred_patrol_speed(self, altitude: Distance) -> Speed: + """Preferred true airspeed when patrolling""" + if self.patrol_speed is not None: + return self.patrol_speed + else: + # Estimate based on max speed. + max_speed = self.max_speed + if max_speed > SPEED_OF_SOUND_AT_SEA_LEVEL * 1.6: + # Fast airplanes, should manage pretty high patrol speed + return ( + Speed.from_mach(0.85, altitude) + if altitude.feet > 20000 + else Speed.from_mach(0.7, altitude) + ) + elif max_speed > SPEED_OF_SOUND_AT_SEA_LEVEL * 1.2: + # Medium-fast like F/A-18C + return ( + Speed.from_mach(0.8, altitude) + if altitude.feet > 20000 + else Speed.from_mach(0.65, altitude) + ) + elif max_speed > SPEED_OF_SOUND_AT_SEA_LEVEL * 0.7: + # Semi-fast like airliners or similar + return ( + Speed.from_mach(0.5, altitude) + if altitude.feet > 20000 + else Speed.from_mach(0.4, altitude) + ) + else: + # Slow like warbirds or helicopters + # Use whichever is slowest - mach 0.35 or 70% of max speed + logging.debug(f"{self.name} max_speed * 0.7 is {max_speed * 0.7}") + return min(Speed.from_mach(0.35, altitude), max_speed * 0.7) + def alloc_flight_radio(self, radio_registry: RadioRegistry) -> RadioFrequency: from gen.radios import ChannelInUseError, kHz @@ -287,7 +329,7 @@ class AircraftType(UnitType[Type[FlyingType]]): logging.warning(f"No data for {aircraft.id}; it will not be available") return - with data_path.open() as data_file: + with data_path.open(encoding="utf-8") as data_file: data = yaml.safe_load(data_file) try: diff --git a/game/dcs/groundunittype.py b/game/dcs/groundunittype.py index c22d8a21..db93aa49 100644 --- a/game/dcs/groundunittype.py +++ b/game/dcs/groundunittype.py @@ -67,7 +67,7 @@ class GroundUnitType(UnitType[Type[VehicleType]]): logging.warning(f"No data for {vehicle.id}; it will not be available") return - with data_path.open() as data_file: + with data_path.open(encoding="utf-8") as data_file: data = yaml.safe_load(data_file) try: diff --git a/game/debriefing.py b/game/debriefing.py index b2a155ad..e756f043 100644 --- a/game/debriefing.py +++ b/game/debriefing.py @@ -386,7 +386,7 @@ class PollDebriefingFileThread(threading.Thread): os.path.isfile("state.json") and os.path.getmtime("state.json") > last_modified ): - with open("state.json", "r") as json_file: + with open("state.json", "r", encoding="utf-8") as json_file: json_data = json.load(json_file) debriefing = Debriefing(json_data, self.game, self.unit_map) self.callback(debriefing) diff --git a/game/event/event.py b/game/event/event.py index 757b9be1..e40cb76f 100644 --- a/game/event/event.py +++ b/game/event/event.py @@ -7,13 +7,12 @@ from dcs.mapping import Point from dcs.task import Task from game import persistency -from game.debriefing import AirLosses, Debriefing +from game.debriefing import Debriefing from game.infos.information import Information from game.operation.operation import Operation from game.theater import ControlPoint -from gen import AirTaskingOrder +from gen.ato import AirTaskingOrder from gen.ground_forces.combat_stance import CombatStance -from ..dcs.groundunittype import GroundUnitType from ..unitmap import UnitMap if TYPE_CHECKING: @@ -67,59 +66,6 @@ class Event: ) return unit_map - @staticmethod - def _transfer_aircraft( - ato: AirTaskingOrder, losses: AirLosses, for_player: bool - ) -> None: - for package in ato.packages: - for flight in package.flights: - # No need to transfer to the same location. - if flight.departure == flight.arrival: - continue - - # Don't transfer to bases that were captured. Note that if the - # airfield was back-filling transfers it may overflow. We could - # attempt to be smarter in the future by performing transfers in - # order up a graph to prevent transfers to full airports and - # send overflow off-map, but overflow is fine for now. - if flight.arrival.captured != for_player: - logging.info( - f"Not transferring {flight} because {flight.arrival} " - "was captured" - ) - continue - - transfer_count = losses.surviving_flight_members(flight) - if transfer_count < 0: - logging.error( - f"{flight} had {flight.count} aircraft but " - f"{transfer_count} losses were recorded." - ) - continue - - aircraft = flight.unit_type - available = flight.departure.base.total_units_of_type(aircraft) - if available < transfer_count: - logging.error( - f"Found killed {aircraft} from {flight.departure} but " - f"that airbase has only {available} available." - ) - continue - - flight.departure.base.aircraft[aircraft] -= transfer_count - if aircraft not in flight.arrival.base.aircraft: - # TODO: Should use defaultdict. - flight.arrival.base.aircraft[aircraft] = 0 - flight.arrival.base.aircraft[aircraft] += transfer_count - - def complete_aircraft_transfers(self, debriefing: Debriefing) -> None: - self._transfer_aircraft( - self.game.blue.ato, debriefing.air_losses, for_player=True - ) - self._transfer_aircraft( - self.game.red.ato, debriefing.air_losses, for_player=False - ) - def commit_air_losses(self, debriefing: Debriefing) -> None: for loss in debriefing.air_losses.losses: if loss.pilot is not None and ( @@ -127,18 +73,18 @@ class Event: or not self.game.settings.invulnerable_player_pilots ): loss.pilot.kill() + squadron = loss.flight.squadron aircraft = loss.flight.unit_type - cp = loss.flight.departure - available = cp.base.total_units_of_type(aircraft) + available = squadron.owned_aircraft if available <= 0: logging.error( - f"Found killed {aircraft} from {cp} but that airbase has " + f"Found killed {aircraft} from {squadron} but that airbase has " "none available." ) continue - logging.info(f"{aircraft} destroyed from {cp}") - cp.base.aircraft[aircraft] -= 1 + logging.info(f"{aircraft} destroyed from {squadron}") + squadron.owned_aircraft -= 1 @staticmethod def _commit_pilot_experience(ato: AirTaskingOrder) -> None: @@ -276,7 +222,6 @@ class Event: self.commit_building_losses(debriefing) self.commit_damaged_runways(debriefing) self.commit_captures(debriefing) - self.complete_aircraft_transfers(debriefing) # Destroyed units carcass # ------------------------- @@ -458,15 +403,10 @@ class Event: source.base.commit_losses(moved_units) # Also transfer pending deliveries. - for unit_type, count in source.pending_unit_deliveries.units.items(): - if not isinstance(unit_type, GroundUnitType): - continue - if count <= 0: - # Don't transfer *sales*... - continue + for unit_type, count in source.ground_unit_orders.units.items(): move_count = int(count * move_factor) - source.pending_unit_deliveries.sell({unit_type: move_count}) - destination.pending_unit_deliveries.order({unit_type: move_count}) + source.ground_unit_orders.sell({unit_type: move_count}) + destination.ground_unit_orders.order({unit_type: move_count}) total_units_redeployed += move_count if total_units_redeployed > 0: diff --git a/game/game.py b/game/game.py index 95f79dd1..2362b6d9 100644 --- a/game/game.py +++ b/game/game.py @@ -1,10 +1,12 @@ +from __future__ import annotations + import itertools import logging import math from collections import Iterator from datetime import date, datetime, timedelta from enum import Enum -from typing import Any, List, Type, Union, cast +from typing import Any, List, Type, Union, cast, TYPE_CHECKING from dcs.countries import Switzerland, UnitedNationsPeacekeepers, USAFAggressors from dcs.country import Country @@ -13,7 +15,6 @@ from dcs.task import CAP, CAS, PinpointStrike from dcs.vehicles import AirDefence from faker import Faker -from game.inventory import GlobalAircraftInventory from game.models.game_stats import GameStats from game.plugins import LuaPluginManager from gen import naming @@ -23,6 +24,7 @@ from gen.flights.closestairfields import ObjectiveDistanceCache from gen.flights.flight import FlightType from gen.ground_forces.ai_ground_planner import GroundPlanner from . import persistency +from .campaignloader import CampaignAirWingConfig from .coalition import Coalition from .debriefing import Debriefing from .event.event import Event @@ -32,7 +34,6 @@ from .infos.information import Information from .navmesh import NavMesh from .profiling import logged_duration from .settings import Settings -from .squadrons import AirWing from .theater import ConflictTheater, ControlPoint from .theater.bullseye import Bullseye from .theater.transitnetwork import TransitNetwork, TransitNetworkBuilder @@ -40,6 +41,9 @@ from .threatzones import ThreatZones from .unitmap import UnitMap from .weather import Conditions, TimeOfDay +if TYPE_CHECKING: + from .squadrons import AirWing + COMMISION_UNIT_VARIETY = 4 COMMISION_LIMITS_SCALE = 1.5 COMMISION_LIMITS_FACTORS = { @@ -86,6 +90,7 @@ class Game: player_faction: Faction, enemy_faction: Faction, theater: ConflictTheater, + air_wing_config: CampaignAirWingConfig, start_date: datetime, settings: Settings, player_budget: float, @@ -120,7 +125,8 @@ class Game: self.blue.set_opponent(self.red) self.red.set_opponent(self.blue) - self.aircraft_inventory = GlobalAircraftInventory(self.theater.controlpoints) + self.blue.configure_default_air_wing(air_wing_config) + self.red.configure_default_air_wing(air_wing_config) self.on_load(game_still_initializing=True) @@ -396,9 +402,9 @@ class Game: # Plan Coalition specific turn if for_blue: - self.initialize_turn_for(player=True) + self.blue.initialize_turn() if for_red: - self.initialize_turn_for(player=False) + self.red.initialize_turn() # Plan GroundWar self.ground_planners = {} @@ -408,12 +414,6 @@ class Game: gplanner.plan_groundwar() self.ground_planners[cp.id] = gplanner - def initialize_turn_for(self, player: bool) -> None: - self.aircraft_inventory.reset(player) - for cp in self.theater.control_points_for(player): - self.aircraft_inventory.set_from_control_point(cp) - self.coalition_for(player).initialize_turn() - def message(self, text: str) -> None: self.informations.append(Information(text, turn=self.turn)) diff --git a/game/unitdelivery.py b/game/groundunitorders.py similarity index 71% rename from game/unitdelivery.py rename to game/groundunitorders.py index cf1af512..187e5373 100644 --- a/game/unitdelivery.py +++ b/game/groundunitorders.py @@ -2,13 +2,11 @@ from __future__ import annotations import logging from collections import defaultdict -from dataclasses import dataclass -from typing import Optional, TYPE_CHECKING, Any +from typing import Optional, TYPE_CHECKING from game.theater import ControlPoint from .coalition import Coalition from .dcs.groundunittype import GroundUnitType -from .dcs.unittype import UnitType from .theater.transitnetwork import ( NoPathError, TransitNetwork, @@ -19,59 +17,41 @@ if TYPE_CHECKING: from .game import Game -@dataclass(frozen=True) -class GroundUnitSource: - control_point: ControlPoint - - -class PendingUnitDeliveries: +class GroundUnitOrders: def __init__(self, destination: ControlPoint) -> None: self.destination = destination # Maps unit type to order quantity. - self.units: dict[UnitType[Any], int] = defaultdict(int) + self.units: dict[GroundUnitType, int] = defaultdict(int) def __str__(self) -> str: - return f"Pending delivery to {self.destination}" + return f"Pending ground unit delivery to {self.destination}" - def order(self, units: dict[UnitType[Any], int]) -> None: + def order(self, units: dict[GroundUnitType, int]) -> None: for k, v in units.items(): self.units[k] += v - def sell(self, units: dict[UnitType[Any], int]) -> None: + def sell(self, units: dict[GroundUnitType, int]) -> None: for k, v in units.items(): - if self.units[k] > v: - self.units[k] -= v - else: + self.units[k] -= v + if self.units[k] == 0: del self.units[k] def refund_all(self, coalition: Coalition) -> None: - self.refund(coalition, self.units) + self._refund(coalition, self.units) self.units = defaultdict(int) - def refund_ground_units(self, coalition: Coalition) -> None: - ground_units: dict[UnitType[Any], int] = { - u: self.units[u] for u in self.units.keys() if isinstance(u, GroundUnitType) - } - self.refund(coalition, ground_units) - for gu in ground_units.keys(): - del self.units[gu] - - def refund(self, coalition: Coalition, units: dict[UnitType[Any], int]) -> None: + def _refund(self, coalition: Coalition, units: dict[GroundUnitType, int]) -> None: for unit_type, count in units.items(): logging.info(f"Refunding {count} {unit_type} at {self.destination.name}") coalition.adjust_budget(unit_type.price * count) - def pending_orders(self, unit_type: UnitType[Any]) -> int: + def pending_orders(self, unit_type: GroundUnitType) -> int: pending_units = self.units.get(unit_type) if pending_units is None: pending_units = 0 return pending_units - def available_next_turn(self, unit_type: UnitType[Any]) -> int: - current_units = self.destination.base.total_units_of_type(unit_type) - return self.pending_orders(unit_type) + current_units - def process(self, game: Game) -> None: coalition = game.coalition_for(self.destination.captured) ground_unit_source = self.find_ground_unit_source(game) @@ -80,36 +60,33 @@ class PendingUnitDeliveries: f"{self.destination.name} lost its source for ground unit " "reinforcements. Refunding purchase price." ) - self.refund_ground_units(coalition) + self.refund_all(coalition) - bought_units: dict[UnitType[Any], int] = {} + bought_units: dict[GroundUnitType, int] = {} units_needing_transfer: dict[GroundUnitType, int] = {} - sold_units: dict[UnitType[Any], int] = {} for unit_type, count in self.units.items(): allegiance = "Ally" if self.destination.captured else "Enemy" - d: dict[Any, int] - if ( - isinstance(unit_type, GroundUnitType) - and self.destination != ground_unit_source - ): + d: dict[GroundUnitType, int] + if self.destination != ground_unit_source: source = ground_unit_source d = units_needing_transfer else: source = self.destination d = bought_units - if count >= 0: + if count < 0: + logging.error( + f"Attempted sale of {unit_type} at {self.destination} but ground " + "units cannot be sold" + ) + elif count > 0: d[unit_type] = count game.message( f"{allegiance} reinforcements: {unit_type} x {count} at {source}" ) - else: - sold_units[unit_type] = -count - game.message(f"{allegiance} sold: {unit_type} x {-count} at {source}") self.units = defaultdict(int) self.destination.base.commission_units(bought_units) - self.destination.base.commit_losses(sold_units) if units_needing_transfer: if ground_unit_source is None: diff --git a/game/inventory.py b/game/inventory.py deleted file mode 100644 index f7f0dbe1..00000000 --- a/game/inventory.py +++ /dev/null @@ -1,142 +0,0 @@ -"""Inventory management APIs.""" -from __future__ import annotations - -from collections import defaultdict, Iterator, Iterable -from typing import TYPE_CHECKING - -from game.dcs.aircrafttype import AircraftType -from gen.flights.flight import Flight - -if TYPE_CHECKING: - from game.theater import ControlPoint - - -class ControlPointAircraftInventory: - """Aircraft inventory for a single control point.""" - - def __init__(self, control_point: ControlPoint) -> None: - self.control_point = control_point - self.inventory: dict[AircraftType, int] = defaultdict(int) - - def clone(self) -> ControlPointAircraftInventory: - new = ControlPointAircraftInventory(self.control_point) - new.inventory = self.inventory.copy() - return new - - def add_aircraft(self, aircraft: AircraftType, count: int) -> None: - """Adds aircraft to the inventory. - - Args: - aircraft: The type of aircraft to add. - count: The number of aircraft to add. - """ - self.inventory[aircraft] += count - - def remove_aircraft(self, aircraft: AircraftType, count: int) -> None: - """Removes aircraft from the inventory. - - Args: - aircraft: The type of aircraft to remove. - count: The number of aircraft to remove. - - Raises: - ValueError: The control point cannot fulfill the requested number of - aircraft. - """ - available = self.inventory[aircraft] - if available < count: - raise ValueError( - f"Cannot remove {count} {aircraft} from " - f"{self.control_point.name}. Only have {available}." - ) - self.inventory[aircraft] -= count - - def available(self, aircraft: AircraftType) -> int: - """Returns the number of available aircraft of the given type. - - Args: - aircraft: The type of aircraft to query. - """ - try: - return self.inventory[aircraft] - except KeyError: - return 0 - - @property - def types_available(self) -> Iterator[AircraftType]: - """Iterates over all available aircraft types.""" - for aircraft, count in self.inventory.items(): - if count > 0: - yield aircraft - - @property - def all_aircraft(self) -> Iterator[tuple[AircraftType, int]]: - """Iterates over all available aircraft types, including amounts.""" - for aircraft, count in self.inventory.items(): - if count > 0: - yield aircraft, count - - def clear(self) -> None: - """Clears all aircraft from the inventory.""" - self.inventory.clear() - - -class GlobalAircraftInventory: - """Game-wide aircraft inventory.""" - - def __init__(self, control_points: Iterable[ControlPoint]) -> None: - self.inventories: dict[ControlPoint, ControlPointAircraftInventory] = { - cp: ControlPointAircraftInventory(cp) for cp in control_points - } - - def clone(self) -> GlobalAircraftInventory: - new = GlobalAircraftInventory([]) - new.inventories = { - cp: inventory.clone() for cp, inventory in self.inventories.items() - } - return new - - def reset(self, for_player: bool) -> None: - """Clears the inventory of every control point owned by the given coalition.""" - for inventory in self.inventories.values(): - if inventory.control_point.captured == for_player: - inventory.clear() - - def set_from_control_point(self, control_point: ControlPoint) -> None: - """Set the control point's aircraft inventory. - - If the inventory for the given control point has already been set for - the turn, it will be overwritten. - """ - inventory = self.inventories[control_point] - for aircraft, count in control_point.base.aircraft.items(): - inventory.add_aircraft(aircraft, count) - - def for_control_point( - self, control_point: ControlPoint - ) -> ControlPointAircraftInventory: - """Returns the inventory specific to the given control point.""" - return self.inventories[control_point] - - @property - def available_types_for_player(self) -> Iterator[AircraftType]: - """Iterates over all aircraft types available to the player.""" - seen: set[AircraftType] = set() - for control_point, inventory in self.inventories.items(): - if control_point.captured: - for aircraft in inventory.types_available: - if not control_point.can_operate(aircraft): - continue - if aircraft not in seen: - seen.add(aircraft) - yield aircraft - - def claim_for_flight(self, flight: Flight) -> None: - """Removes aircraft from the inventory for the given flight.""" - inventory = self.for_control_point(flight.from_cp) - inventory.remove_aircraft(flight.unit_type, flight.count) - - def return_from_flight(self, flight: Flight) -> None: - """Returns a flight's aircraft to the inventory.""" - inventory = self.for_control_point(flight.from_cp) - inventory.add_aircraft(flight.unit_type, flight.count) diff --git a/game/models/game_stats.py b/game/models/game_stats.py index 7e828021..780565d4 100644 --- a/game/models/game_stats.py +++ b/game/models/game_stats.py @@ -56,10 +56,12 @@ class GameStats: for cp in game.theater.controlpoints: if cp.captured: - turn_data.allied_units.aircraft_count += sum(cp.base.aircraft.values()) + for squadron in cp.squadrons: + turn_data.allied_units.aircraft_count += squadron.owned_aircraft turn_data.allied_units.vehicles_count += sum(cp.base.armor.values()) else: - turn_data.enemy_units.aircraft_count += sum(cp.base.aircraft.values()) + for squadron in cp.squadrons: + turn_data.enemy_units.aircraft_count += squadron.owned_aircraft turn_data.enemy_units.vehicles_count += sum(cp.base.armor.values()) self.data_per_turn.append(turn_data) diff --git a/game/operation/operation.py b/game/operation/operation.py index 1f632fc3..939192f0 100644 --- a/game/operation/operation.py +++ b/game/operation/operation.py @@ -16,16 +16,18 @@ from dcs.triggers import TriggerStart from game.plugins import LuaPluginManager from game.theater.theatergroundobject import TheaterGroundObject -from gen import Conflict, FlightType, VisualGenerator, Bullseye, AirSupport from gen.aircraft import AircraftConflictGenerator, FlightData from gen.airfields import AIRFIELD_DATA +from gen.airsupport import AirSupport from gen.airsupportgen import AirSupportConflictGenerator from gen.armor import GroundConflictGenerator from gen.beacons import load_beacons_for_terrain from gen.briefinggen import BriefingGenerator, MissionInfoGenerator from gen.cargoshipgen import CargoShipGenerator +from gen.conflictgen import Conflict from gen.convoygen import ConvoyGenerator from gen.environmentgen import EnvironmentGenerator +from gen.flights.flight import FlightType from gen.forcedoptionsgen import ForcedOptionsGenerator from gen.groundobjectsgen import GroundObjectsGenerator from gen.kneeboard import KneeboardGenerator @@ -34,6 +36,7 @@ from gen.naming import namegen from gen.radios import RadioFrequency, RadioRegistry from gen.tacan import TacanRegistry from gen.triggergen import TRIGGER_RADIUS_MEDIUM, TriggersGenerator +from gen.visualgen import VisualGenerator from .. import db from ..theater import Airfield, FrontLine from ..unitmap import UnitMap @@ -65,7 +68,7 @@ class Operation: @classmethod def prepare(cls, game: Game) -> None: - with open("resources/default_options.lua", "r") as f: + with open("resources/default_options.lua", "r", encoding="utf-8") as f: options_dict = loads(f.read())["options"] cls._set_mission(Mission(game.theater.terrain)) cls.game = game diff --git a/game/procurement.py b/game/procurement.py index 950e19d0..46048170 100644 --- a/game/procurement.py +++ b/game/procurement.py @@ -2,14 +2,14 @@ from __future__ import annotations import math import random -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import Iterator, List, Optional, TYPE_CHECKING, Tuple from game import db from game.data.groundunitclass import GroundUnitClass -from game.dcs.aircrafttype import AircraftType from game.dcs.groundunittype import GroundUnitType from game.factions.faction import Faction +from game.squadrons import Squadron from game.theater import ControlPoint, MissionTarget from game.utils import meters from gen.flights.ai_flight_planner_db import aircraft_for_task @@ -97,37 +97,10 @@ class ProcurementAi: budget -= armor_budget budget += self.reinforce_front_line(armor_budget) - # Don't sell overstock aircraft until after we've bought runways and - # front lines. Any budget we free up should be earmarked for aircraft. - if not self.is_player: - budget += self.sell_incomplete_squadrons() if self.manage_aircraft: budget = self.purchase_aircraft(budget) return budget - def sell_incomplete_squadrons(self) -> float: - # Selling incomplete squadrons gives us more money to spend on the next - # turn. This serves as a short term fix for - # https://github.com/dcs-liberation/dcs_liberation/issues/41. - # - # Only incomplete squadrons which are unlikely to get used will be sold - # rather than all unused aircraft because the unused aircraft are what - # make OCA strikes worthwhile. - # - # This option is only used by the AI since players cannot cancel sales - # (https://github.com/dcs-liberation/dcs_liberation/issues/365). - total = 0.0 - for cp in self.game.theater.control_points_for(self.is_player): - inventory = self.game.aircraft_inventory.for_control_point(cp) - for aircraft, available in inventory.all_aircraft: - # We only ever plan even groups, so the odd aircraft is unlikely - # to get used. - if available % 2 == 0: - continue - inventory.remove_aircraft(aircraft, 1) - total += aircraft.price - return total - def repair_runways(self, budget: float) -> float: for control_point in self.owned_points: if budget < db.RUNWAY_REPAIR_COST: @@ -180,7 +153,7 @@ class ProcurementAi: break budget -= unit.price - cp.pending_unit_deliveries.order({unit: 1}) + cp.ground_unit_orders.order({unit: 1}) return budget @@ -209,64 +182,29 @@ class ProcurementAi: return GroundUnitClass.Tank return worst_balanced - def affordable_aircraft_for( - self, request: AircraftProcurementRequest, airbase: ControlPoint, budget: float - ) -> Optional[AircraftType]: - best_choice: Optional[AircraftType] = None - for unit in aircraft_for_task(request.task_capability): - if unit not in self.faction.aircrafts: - continue - if unit.price * request.number > budget: - continue - if not airbase.can_operate(unit): - continue - - distance_to_target = meters(request.near.distance_to(airbase)) - if distance_to_target > unit.max_mission_range: - continue - - for squadron in self.air_wing.squadrons_for(unit): - if ( - squadron.operates_from(airbase) - and request.task_capability - in squadron.auto_assignable_mission_types - ): - break - else: - continue - - # Affordable, compatible, and we have a squadron capable of the task. To - # keep some variety, skip with a 50/50 chance. Might be a good idea to have - # the chance to skip based on the price compared to the rest of the choices. - best_choice = unit - if random.choice([True, False]): - break - return best_choice - + @staticmethod def fulfill_aircraft_request( - self, request: AircraftProcurementRequest, budget: float + squadrons: list[Squadron], quantity: int, budget: float ) -> Tuple[float, bool]: - for airbase in self.best_airbases_for(request): - unit = self.affordable_aircraft_for(request, airbase, budget) - if unit is None: - # Can't afford any aircraft capable of performing the - # required mission that can operate from this airbase. We - # might be able to afford aircraft at other airbases though, - # in the case where the airbase we attempted to use is only - # able to operate expensive aircraft. + for squadron in squadrons: + price = squadron.aircraft.price * quantity + if price > budget: continue - budget -= unit.price * request.number - airbase.pending_unit_deliveries.order({unit: request.number}) + squadron.pending_deliveries += quantity + budget -= price return budget, True return budget, False def purchase_aircraft(self, budget: float) -> float: for request in self.game.coalition_for(self.is_player).procurement_requests: - if not list(self.best_airbases_for(request)): + squadrons = list(self.best_squadrons_for(request)) + if not squadrons: # No airbases in range of this request. Skip it. continue - budget, fulfilled = self.fulfill_aircraft_request(request, budget) + budget, fulfilled = self.fulfill_aircraft_request( + squadrons, request.number, budget + ) if not fulfilled: # The request was not fulfilled because we could not afford any suitable # aircraft. Rather than continuing, which could proceed to buy tons of @@ -283,9 +221,32 @@ class ProcurementAi: else: return self.game.theater.enemy_points() - def best_airbases_for( + @staticmethod + def squadron_rank_for_task(squadron: Squadron, task: FlightType) -> int: + return aircraft_for_task(task).index(squadron.aircraft) + + def compatible_squadrons_at_airbase( + self, airbase: ControlPoint, request: AircraftProcurementRequest + ) -> Iterator[Squadron]: + compatible: list[Squadron] = [] + for squadron in airbase.squadrons: + if not squadron.can_auto_assign(request.task_capability): + continue + if not squadron.can_provide_pilots(request.number): + continue + + distance_to_target = meters(request.near.distance_to(airbase)) + if distance_to_target > squadron.aircraft.max_mission_range: + continue + compatible.append(squadron) + yield from sorted( + compatible, + key=lambda s: self.squadron_rank_for_task(s, request.task_capability), + ) + + def best_squadrons_for( self, request: AircraftProcurementRequest - ) -> Iterator[ControlPoint]: + ) -> Iterator[Squadron]: distance_cache = ObjectiveDistanceCache.get_closest_airfields(request.near) threatened = [] for cp in distance_cache.operational_airfields: @@ -295,8 +256,10 @@ class ProcurementAi: continue if self.threat_zones.threatened(cp.position): threatened.append(cp) - yield cp - yield from threatened + continue + yield from self.compatible_squadrons_at_airbase(cp, request) + for threatened_base in threatened: + yield from self.compatible_squadrons_at_airbase(threatened_base, request) def ground_reinforcement_candidate(self) -> Optional[ControlPoint]: worst_supply = math.inf diff --git a/game/purchaseadapter.py b/game/purchaseadapter.py new file mode 100644 index 00000000..6376f15c --- /dev/null +++ b/game/purchaseadapter.py @@ -0,0 +1,180 @@ +from abc import abstractmethod +from typing import TypeVar, Generic + +from game import Game +from game.coalition import Coalition +from game.dcs.groundunittype import GroundUnitType +from game.squadrons import Squadron +from game.theater import ControlPoint + +ItemType = TypeVar("ItemType") + + +class TransactionError(RuntimeError): + def __init__(self, message: str) -> None: + super().__init__(message) + + +class PurchaseAdapter(Generic[ItemType]): + def __init__(self, coalition: Coalition) -> None: + self.coalition = coalition + + def buy(self, item: ItemType, quantity: int) -> None: + for _ in range(quantity): + if self.has_pending_sales(item): + self.do_cancel_sale(item) + elif self.can_buy(item): + self.do_purchase(item) + else: + raise TransactionError(f"Cannot buy more {item}") + self.coalition.adjust_budget(-self.price_of(item)) + + def sell(self, item: ItemType, quantity: int) -> None: + for _ in range(quantity): + if self.has_pending_orders(item): + self.do_cancel_purchase(item) + elif self.can_sell(item): + self.do_sale(item) + else: + raise TransactionError(f"Cannot sell more {item}") + self.coalition.adjust_budget(self.price_of(item)) + + def has_pending_orders(self, item: ItemType) -> bool: + return self.pending_delivery_quantity(item) > 0 + + def has_pending_sales(self, item: ItemType) -> bool: + return self.pending_delivery_quantity(item) < 0 + + @abstractmethod + def current_quantity_of(self, item: ItemType) -> int: + ... + + @abstractmethod + def pending_delivery_quantity(self, item: ItemType) -> int: + ... + + def expected_quantity_next_turn(self, item: ItemType) -> int: + return self.current_quantity_of(item) + self.pending_delivery_quantity(item) + + def can_buy(self, item: ItemType) -> bool: + return self.coalition.budget >= self.price_of(item) + + def can_sell_or_cancel(self, item: ItemType) -> bool: + return self.can_sell(item) or self.has_pending_orders(item) + + @abstractmethod + def can_sell(self, item: ItemType) -> bool: + ... + + @abstractmethod + def do_purchase(self, item: ItemType) -> None: + ... + + @abstractmethod + def do_cancel_purchase(self, item: ItemType) -> None: + ... + + @abstractmethod + def do_sale(self, item: ItemType) -> None: + ... + + @abstractmethod + def do_cancel_sale(self, item: ItemType) -> None: + ... + + @abstractmethod + def price_of(self, item: ItemType) -> int: + ... + + @abstractmethod + def name_of(self, item: ItemType, multiline: bool = False) -> str: + ... + + +class AircraftPurchaseAdapter(PurchaseAdapter[Squadron]): + def __init__( + self, control_point: ControlPoint, coalition: Coalition, game: Game + ) -> None: + super().__init__(coalition) + self.control_point = control_point + self.game = game + + def pending_delivery_quantity(self, item: Squadron) -> int: + return item.pending_deliveries + + def current_quantity_of(self, item: Squadron) -> int: + return item.owned_aircraft + + def can_buy(self, item: Squadron) -> bool: + return ( + super().can_buy(item) + and self.control_point.unclaimed_parking(self.game) > 0 + ) + + def can_sell(self, item: Squadron) -> bool: + return item.untasked_aircraft > 0 + + def do_purchase(self, item: Squadron) -> None: + item.pending_deliveries += 1 + + def do_cancel_purchase(self, item: Squadron) -> None: + item.pending_deliveries -= 1 + + def do_sale(self, item: Squadron) -> None: + item.untasked_aircraft -= 1 + item.pending_deliveries -= 1 + + def do_cancel_sale(self, item: Squadron) -> None: + item.untasked_aircraft += 1 + item.pending_deliveries += 1 + + def price_of(self, item: Squadron) -> int: + return item.aircraft.price + + def name_of(self, item: Squadron, multiline: bool = False) -> str: + if multiline: + separator = "
" + else: + separator = " " + return separator.join([item.aircraft.name, str(item)]) + + +class GroundUnitPurchaseAdapter(PurchaseAdapter[GroundUnitType]): + def __init__( + self, control_point: ControlPoint, coalition: Coalition, game: Game + ) -> None: + super().__init__(coalition) + self.control_point = control_point + self.game = game + + def pending_delivery_quantity(self, item: GroundUnitType) -> int: + return self.control_point.ground_unit_orders.pending_orders(item) + + def current_quantity_of(self, item: GroundUnitType) -> int: + return self.control_point.base.total_units_of_type(item) + + def can_buy(self, item: GroundUnitType) -> bool: + return super().can_buy(item) and self.control_point.has_ground_unit_source( + self.game + ) + + def can_sell(self, item: GroundUnitType) -> bool: + return False + + def do_purchase(self, item: GroundUnitType) -> None: + self.control_point.ground_unit_orders.order({item: 1}) + + def do_cancel_purchase(self, item: GroundUnitType) -> None: + self.control_point.ground_unit_orders.sell({item: 1}) + + def do_sale(self, item: GroundUnitType) -> None: + raise TransactionError("Sale of ground units not allowed") + + def do_cancel_sale(self, item: GroundUnitType) -> None: + raise TransactionError("Sale of ground units not allowed") + + def price_of(self, item: GroundUnitType) -> int: + return item.price + + def name_of(self, item: GroundUnitType, multiline: bool = False) -> str: + return f"{item}" diff --git a/game/radio/channels.py b/game/radio/channels.py index 4fbf7e23..214596fe 100644 --- a/game/radio/channels.py +++ b/game/radio/channels.py @@ -4,7 +4,8 @@ from dataclasses import dataclass from typing import Optional, Any, TYPE_CHECKING if TYPE_CHECKING: - from gen import FlightData, AirSupport + from gen.aircraft import FlightData + from gen.airsupport import AirSupport class RadioChannelAllocator: diff --git a/game/settings.py b/game/settings.py index e76e0816..3bb557bf 100644 --- a/game/settings.py +++ b/game/settings.py @@ -16,78 +16,81 @@ class AutoAtoBehavior(Enum): @dataclass class Settings: + version: Optional[str] = None # Difficulty settings + # AI Difficulty player_skill: str = "Good" enemy_skill: str = "Average" - ai_pilot_levelling: bool = True enemy_vehicle_skill: str = "Average" - map_coalition_visibility: ForcedOptions.Views = ForcedOptions.Views.All - labels: str = "Full" - only_player_takeoff: bool = True # Legacy parameter do not use - night_disabled: bool = False - external_views_allowed: bool = True - supercarrier: bool = False - generate_marks: bool = True - manpads: bool = True - version: Optional[str] = None player_income_multiplier: float = 1.0 enemy_income_multiplier: float = 1.0 + invulnerable_player_pilots: bool = True + # Mission Difficulty + night_disabled: bool = False + manpads: bool = True + # Mission Restrictions + labels: str = "Full" + map_coalition_visibility: ForcedOptions.Views = ForcedOptions.Views.All + external_views_allowed: bool = True + battle_damage_assessment: Optional[bool] = None + # Campaign management + # General + restrict_weapons_by_date: bool = False + disable_legacy_aewc: bool = True + disable_legacy_tanker: bool = True + # Pilots and Squadrons + ai_pilot_levelling: bool = True #: Feature flag for squadron limits. enable_squadron_pilot_limits: bool = False - #: The maximum number of pilots a squadron can have at one time. Changing this after #: the campaign has started will have no immediate effect; pilots already in the #: squadron will not be removed if the limit is lowered and pilots will not be #: immediately created if the limit is raised. squadron_pilot_limit: int = 12 - #: The number of pilots a squadron can replace per turn. squadron_replenishment_rate: int = 4 - - default_start_type: str = "Cold" - - # Mission specific - desired_player_mission_duration: timedelta = timedelta(minutes=60) - - # Campaign management + # HQ Automation automate_runway_repair: bool = False automate_front_line_reinforcements: bool = False automate_aircraft_reinforcements: bool = False - automate_front_line_stance: bool = True - restrict_weapons_by_date: bool = False - disable_legacy_aewc: bool = True - disable_legacy_tanker: bool = True - generate_dark_kneeboard: bool = False - invulnerable_player_pilots: bool = True auto_ato_behavior: AutoAtoBehavior = AutoAtoBehavior.Default auto_ato_player_missions_asap: bool = True + automate_front_line_stance: bool = True + reserves_procurement_target: int = 10 - # Performance oriented - perf_red_alert_state: bool = True + # Mission Generator + # Gameplay + supercarrier: bool = False + generate_marks: bool = True + generate_dark_kneeboard: bool = False + never_delay_player_flights: bool = False + default_start_type: str = "Cold" + # Mission specific + desired_player_mission_duration: timedelta = timedelta(minutes=60) + # Performance perf_smoke_gen: bool = True perf_smoke_spacing = 1600 + perf_red_alert_state: bool = True perf_artillery: bool = True perf_moving_units: bool = True perf_infantry: bool = True perf_destroyed_units: bool = True - reserves_procurement_target: int = 10 - # Performance culling perf_culling: bool = False perf_culling_distance: int = 100 perf_do_not_cull_carrier = True - # LUA Plugins system - plugins: Dict[str, bool] = field(default_factory=dict) - # Cheating show_red_ato: bool = False enable_frontline_cheats: bool = False enable_base_capture_cheat: bool = False - never_delay_player_flights: bool = False + # LUA Plugins system + plugins: Dict[str, bool] = field(default_factory=dict) + + only_player_takeoff: bool = True # Legacy parameter do not use @staticmethod def plugin_settings_key(identifier: str) -> str: diff --git a/game/squadrons.py b/game/squadrons.py deleted file mode 100644 index 5777102f..00000000 --- a/game/squadrons.py +++ /dev/null @@ -1,503 +0,0 @@ -from __future__ import annotations - -import dataclasses -import itertools -import logging -import random -from collections import defaultdict -from dataclasses import dataclass, field -from enum import unique, Enum -from pathlib import Path -from typing import ( - Tuple, - TYPE_CHECKING, - Optional, - Iterator, - Sequence, - Any, -) - -import yaml -from faker import Faker - -from game.dcs.aircrafttype import AircraftType -from game.settings import AutoAtoBehavior, Settings - -if TYPE_CHECKING: - from game import Game - from game.coalition import Coalition - from gen.flights.flight import FlightType - from game.theater import ControlPoint - - -@dataclass -class PilotRecord: - missions_flown: int = field(default=0) - - -@unique -class PilotStatus(Enum): - Active = "Active" - OnLeave = "On leave" - Dead = "Dead" - - -@dataclass -class Pilot: - name: str - player: bool = field(default=False) - status: PilotStatus = field(default=PilotStatus.Active) - record: PilotRecord = field(default_factory=PilotRecord) - - @property - def alive(self) -> bool: - return self.status is not PilotStatus.Dead - - @property - def on_leave(self) -> bool: - return self.status is PilotStatus.OnLeave - - def send_on_leave(self) -> None: - if self.status is not PilotStatus.Active: - raise RuntimeError("Only active pilots may be sent on leave") - self.status = PilotStatus.OnLeave - - def return_from_leave(self) -> None: - if self.status is not PilotStatus.OnLeave: - raise RuntimeError("Only pilots on leave may be returned from leave") - self.status = PilotStatus.Active - - def kill(self) -> None: - self.status = PilotStatus.Dead - - @classmethod - def random(cls, faker: Faker) -> Pilot: - return Pilot(faker.name()) - - -@dataclass(frozen=True) -class OperatingBases: - shore: bool - carrier: bool - lha: bool - - @classmethod - def default_for_aircraft(cls, aircraft: AircraftType) -> OperatingBases: - if aircraft.dcs_unit_type.helicopter: - # Helicopters operate from anywhere by default. - return OperatingBases(shore=True, carrier=True, lha=True) - if aircraft.lha_capable: - # Marine aircraft operate from LHAs and the shore by default. - return OperatingBases(shore=True, carrier=False, lha=True) - if aircraft.carrier_capable: - # Carrier aircraft operate from carriers by default. - return OperatingBases(shore=False, carrier=True, lha=False) - # And the rest are only capable of shore operation. - return OperatingBases(shore=True, carrier=False, lha=False) - - @classmethod - def from_yaml(cls, aircraft: AircraftType, data: dict[str, bool]) -> OperatingBases: - return dataclasses.replace( - OperatingBases.default_for_aircraft(aircraft), **data - ) - - -@dataclass -class Squadron: - name: str - nickname: Optional[str] - country: str - role: str - aircraft: AircraftType - livery: Optional[str] - mission_types: tuple[FlightType, ...] - operating_bases: OperatingBases - - #: The pool of pilots that have not yet been assigned to the squadron. This only - #: happens when a preset squadron defines more preset pilots than the squadron limit - #: allows. This pool will be consumed before random pilots are generated. - pilot_pool: list[Pilot] - - current_roster: list[Pilot] = field(default_factory=list, init=False, hash=False) - available_pilots: list[Pilot] = field( - default_factory=list, init=False, hash=False, compare=False - ) - - auto_assignable_mission_types: set[FlightType] = field( - init=False, hash=False, compare=False - ) - - coalition: Coalition = field(hash=False, compare=False) - settings: Settings = field(hash=False, compare=False) - - def __post_init__(self) -> None: - self.auto_assignable_mission_types = set(self.mission_types) - - def __str__(self) -> str: - if self.nickname is None: - return self.name - return f'{self.name} "{self.nickname}"' - - @property - def player(self) -> bool: - return self.coalition.player - - @property - def pilot_limits_enabled(self) -> bool: - return self.settings.enable_squadron_pilot_limits - - def claim_new_pilot_if_allowed(self) -> Optional[Pilot]: - if self.pilot_limits_enabled: - return None - self._recruit_pilots(1) - return self.available_pilots.pop() - - def claim_available_pilot(self) -> Optional[Pilot]: - if not self.available_pilots: - return self.claim_new_pilot_if_allowed() - - # For opfor, so player/AI option is irrelevant. - if not self.player: - return self.available_pilots.pop() - - preference = self.settings.auto_ato_behavior - - # No preference, so the first pilot is fine. - if preference is AutoAtoBehavior.Default: - return self.available_pilots.pop() - - prefer_players = preference is AutoAtoBehavior.Prefer - for pilot in self.available_pilots: - if pilot.player == prefer_players: - self.available_pilots.remove(pilot) - return pilot - - # No pilot was found that matched the user's preference. - # - # If they chose to *never* assign players and only players remain in the pool, - # we cannot fill the slot with the available pilots. - # - # If they only *prefer* players and we're out of players, just return an AI - # pilot. - if not prefer_players: - return self.claim_new_pilot_if_allowed() - return self.available_pilots.pop() - - def claim_pilot(self, pilot: Pilot) -> None: - if pilot not in self.available_pilots: - raise ValueError( - f"Cannot assign {pilot} to {self} because they are not available" - ) - self.available_pilots.remove(pilot) - - def return_pilot(self, pilot: Pilot) -> None: - self.available_pilots.append(pilot) - - def return_pilots(self, pilots: Sequence[Pilot]) -> None: - # Return in reverse so that returning two pilots and then getting two more - # results in the same ordering. This happens commonly when resetting rosters in - # the UI, when we clear the roster because the UI is updating, then end up - # repopulating the same size flight from the same squadron. - self.available_pilots.extend(reversed(pilots)) - - def _recruit_pilots(self, count: int) -> None: - new_pilots = self.pilot_pool[:count] - self.pilot_pool = self.pilot_pool[count:] - count -= len(new_pilots) - new_pilots.extend([Pilot(self.faker.name()) for _ in range(count)]) - self.current_roster.extend(new_pilots) - self.available_pilots.extend(new_pilots) - - def populate_for_turn_0(self) -> None: - if any(p.status is not PilotStatus.Active for p in self.pilot_pool): - raise ValueError("Squadrons can only be created with active pilots.") - self._recruit_pilots(self.settings.squadron_pilot_limit) - - def replenish_lost_pilots(self) -> None: - if not self.pilot_limits_enabled: - return - - replenish_count = min( - self.settings.squadron_replenishment_rate, - self._number_of_unfilled_pilot_slots, - ) - if replenish_count > 0: - self._recruit_pilots(replenish_count) - - def return_all_pilots(self) -> None: - self.available_pilots = list(self.active_pilots) - - @staticmethod - def send_on_leave(pilot: Pilot) -> None: - pilot.send_on_leave() - - def return_from_leave(self, pilot: Pilot) -> None: - if not self.has_unfilled_pilot_slots: - raise RuntimeError( - f"Cannot return {pilot} from leave because {self} is full" - ) - pilot.return_from_leave() - - @property - def faker(self) -> Faker: - return self.coalition.faker - - def _pilots_with_status(self, status: PilotStatus) -> list[Pilot]: - return [p for p in self.current_roster if p.status == status] - - def _pilots_without_status(self, status: PilotStatus) -> list[Pilot]: - return [p for p in self.current_roster if p.status != status] - - @property - def active_pilots(self) -> list[Pilot]: - return self._pilots_with_status(PilotStatus.Active) - - @property - def pilots_on_leave(self) -> list[Pilot]: - return self._pilots_with_status(PilotStatus.OnLeave) - - @property - def number_of_pilots_including_inactive(self) -> int: - return len(self.current_roster) - - @property - def _number_of_unfilled_pilot_slots(self) -> int: - return self.settings.squadron_pilot_limit - len(self.active_pilots) - - @property - def number_of_available_pilots(self) -> int: - return len(self.available_pilots) - - def can_provide_pilots(self, count: int) -> bool: - return not self.pilot_limits_enabled or self.number_of_available_pilots >= count - - @property - def has_available_pilots(self) -> bool: - return not self.pilot_limits_enabled or bool(self.available_pilots) - - @property - def has_unfilled_pilot_slots(self) -> bool: - return not self.pilot_limits_enabled or self._number_of_unfilled_pilot_slots > 0 - - def can_auto_assign(self, task: FlightType) -> bool: - return task in self.auto_assignable_mission_types - - def operates_from(self, control_point: ControlPoint) -> bool: - if control_point.is_carrier: - return self.operating_bases.carrier - elif control_point.is_lha: - return self.operating_bases.lha - else: - return self.operating_bases.shore - - def pilot_at_index(self, index: int) -> Pilot: - return self.current_roster[index] - - @classmethod - def from_yaml(cls, path: Path, game: Game, coalition: Coalition) -> Squadron: - from gen.flights.ai_flight_planner_db import tasks_for_aircraft - from gen.flights.flight import FlightType - - with path.open(encoding="utf8") as squadron_file: - data = yaml.safe_load(squadron_file) - - name = data["aircraft"] - try: - unit_type = AircraftType.named(name) - except KeyError as ex: - raise KeyError(f"Could not find any aircraft named {name}") from ex - - pilots = [Pilot(n, player=False) for n in data.get("pilots", [])] - pilots.extend([Pilot(n, player=True) for n in data.get("players", [])]) - - mission_types = [FlightType.from_name(n) for n in data["mission_types"]] - tasks = tasks_for_aircraft(unit_type) - for mission_type in list(mission_types): - if mission_type not in tasks: - logging.error( - f"Squadron has mission type {mission_type} but {unit_type} is not " - f"capable of that task: {path}" - ) - mission_types.remove(mission_type) - - return Squadron( - name=data["name"], - nickname=data.get("nickname"), - country=data["country"], - role=data["role"], - aircraft=unit_type, - livery=data.get("livery"), - mission_types=tuple(mission_types), - operating_bases=OperatingBases.from_yaml(unit_type, data.get("bases", {})), - pilot_pool=pilots, - coalition=coalition, - settings=game.settings, - ) - - def __setstate__(self, state: dict[str, Any]) -> None: - # TODO: Remove save compat. - if "auto_assignable_mission_types" not in state: - state["auto_assignable_mission_types"] = set(state["mission_types"]) - self.__dict__.update(state) - - -class SquadronLoader: - def __init__(self, game: Game, coalition: Coalition) -> None: - self.game = game - self.coalition = coalition - - @staticmethod - def squadron_directories() -> Iterator[Path]: - from game import persistency - - yield Path(persistency.base_path()) / "Liberation/Squadrons" - yield Path("resources/squadrons") - - def load(self) -> dict[AircraftType, list[Squadron]]: - squadrons: dict[AircraftType, list[Squadron]] = defaultdict(list) - country = self.coalition.country_name - faction = self.coalition.faction - any_country = country.startswith("Combined Joint Task Forces ") - for directory in self.squadron_directories(): - for path, squadron in self.load_squadrons_from(directory): - if not any_country and squadron.country != country: - logging.debug( - "Not using squadron for non-matching country (is " - f"{squadron.country}, need {country}: {path}" - ) - continue - if squadron.aircraft not in faction.aircrafts: - logging.debug( - f"Not using squadron because {faction.name} cannot use " - f"{squadron.aircraft}: {path}" - ) - continue - logging.debug( - f"Found {squadron.name} {squadron.aircraft} {squadron.role} " - f"compatible with {faction.name}" - ) - squadrons[squadron.aircraft].append(squadron) - # Convert away from defaultdict because defaultdict doesn't unpickle so we don't - # want it in the save state. - return dict(squadrons) - - def load_squadrons_from(self, directory: Path) -> Iterator[Tuple[Path, Squadron]]: - logging.debug(f"Looking for factions in {directory}") - # First directory level is the aircraft type so that historical squadrons that - # have flown multiple airframes can be defined as many times as needed. The main - # load() method is responsible for filtering out squadrons that aren't - # compatible with the faction. - for squadron_path in directory.glob("*/*.yaml"): - try: - yield squadron_path, Squadron.from_yaml( - squadron_path, self.game, self.coalition - ) - except Exception as ex: - raise RuntimeError( - f"Failed to load squadron defined by {squadron_path}" - ) from ex - - -class AirWing: - def __init__(self, game: Game, coalition: Coalition) -> None: - from gen.flights.ai_flight_planner_db import tasks_for_aircraft - - self.game = game - self.squadrons = SquadronLoader(game, coalition).load() - - count = itertools.count(1) - for aircraft in coalition.faction.aircrafts: - if aircraft in self.squadrons: - continue - self.squadrons[aircraft] = [ - Squadron( - name=f"Squadron {next(count):03}", - nickname=self.random_nickname(), - country=coalition.country_name, - role="Flying Squadron", - aircraft=aircraft, - livery=None, - mission_types=tuple(tasks_for_aircraft(aircraft)), - operating_bases=OperatingBases.default_for_aircraft(aircraft), - pilot_pool=[], - coalition=coalition, - settings=game.settings, - ) - ] - - def squadrons_for(self, aircraft: AircraftType) -> Sequence[Squadron]: - return self.squadrons[aircraft] - - def can_auto_plan(self, task: FlightType) -> bool: - try: - next(self.auto_assignable_for_task(task)) - return True - except StopIteration: - return False - - def auto_assignable_for_task(self, task: FlightType) -> Iterator[Squadron]: - for squadron in self.iter_squadrons(): - if squadron.can_auto_assign(task): - yield squadron - - def auto_assignable_for_task_with_type( - self, aircraft: AircraftType, task: FlightType - ) -> Iterator[Squadron]: - for squadron in self.squadrons_for(aircraft): - if squadron.can_auto_assign(task) and squadron.has_available_pilots: - yield squadron - - def squadron_for(self, aircraft: AircraftType) -> Squadron: - return self.squadrons_for(aircraft)[0] - - def iter_squadrons(self) -> Iterator[Squadron]: - return itertools.chain.from_iterable(self.squadrons.values()) - - def squadron_at_index(self, index: int) -> Squadron: - return list(self.iter_squadrons())[index] - - def populate_for_turn_0(self) -> None: - for squadron in self.iter_squadrons(): - squadron.populate_for_turn_0() - - def replenish(self) -> None: - for squadron in self.iter_squadrons(): - squadron.replenish_lost_pilots() - - def reset(self) -> None: - for squadron in self.iter_squadrons(): - squadron.return_all_pilots() - - @property - def size(self) -> int: - return sum(len(s) for s in self.squadrons.values()) - - @staticmethod - def _make_random_nickname() -> str: - from gen.naming import ANIMALS - - animal = random.choice(ANIMALS) - adjective = random.choice( - ( - None, - "Red", - "Blue", - "Green", - "Golden", - "Black", - "Fighting", - "Flying", - ) - ) - if adjective is None: - return animal.title() - return f"{adjective} {animal}".title() - - def random_nickname(self) -> str: - while True: - nickname = self._make_random_nickname() - for squadron in self.iter_squadrons(): - if squadron.nickname == nickname: - break - else: - return nickname diff --git a/game/squadrons/__init__.py b/game/squadrons/__init__.py new file mode 100644 index 00000000..99fdd47a --- /dev/null +++ b/game/squadrons/__init__.py @@ -0,0 +1,3 @@ +from .airwing import AirWing +from .pilot import Pilot +from .squadron import Squadron diff --git a/game/squadrons/airwing.py b/game/squadrons/airwing.py new file mode 100644 index 00000000..74ad5f83 --- /dev/null +++ b/game/squadrons/airwing.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import itertools +from collections import defaultdict +from typing import Sequence, Iterator, TYPE_CHECKING + +from game.dcs.aircrafttype import AircraftType +from .squadron import Squadron +from ..theater import ControlPoint + +if TYPE_CHECKING: + from game import Game + from gen.flights.flight import FlightType + + +class AirWing: + def __init__(self, game: Game) -> None: + self.game = game + self.squadrons: dict[AircraftType, list[Squadron]] = defaultdict(list) + + def add_squadron(self, squadron: Squadron) -> None: + squadron.location.squadrons.append(squadron) + self.squadrons[squadron.aircraft].append(squadron) + + def squadrons_for(self, aircraft: AircraftType) -> Sequence[Squadron]: + return self.squadrons[aircraft] + + def can_auto_plan(self, task: FlightType) -> bool: + try: + next(self.auto_assignable_for_task(task)) + return True + except StopIteration: + return False + + @property + def available_aircraft_types(self) -> Iterator[AircraftType]: + for aircraft, squadrons in self.squadrons.items(): + for squadron in squadrons: + if squadron.untasked_aircraft: + yield aircraft + break + + def auto_assignable_for_task(self, task: FlightType) -> Iterator[Squadron]: + for squadron in self.iter_squadrons(): + if squadron.can_auto_assign(task): + yield squadron + + def auto_assignable_for_task_at( + self, task: FlightType, base: ControlPoint + ) -> Iterator[Squadron]: + for squadron in self.iter_squadrons(): + if squadron.can_auto_assign(task) and squadron.location == base: + yield squadron + + def auto_assignable_for_task_with_type( + self, aircraft: AircraftType, task: FlightType, base: ControlPoint + ) -> Iterator[Squadron]: + for squadron in self.squadrons_for(aircraft): + if ( + squadron.location == base + and squadron.can_auto_assign(task) + and squadron.has_available_pilots + ): + yield squadron + + def squadron_for(self, aircraft: AircraftType) -> Squadron: + return self.squadrons_for(aircraft)[0] + + def iter_squadrons(self) -> Iterator[Squadron]: + return itertools.chain.from_iterable(self.squadrons.values()) + + def squadron_at_index(self, index: int) -> Squadron: + return list(self.iter_squadrons())[index] + + def populate_for_turn_0(self) -> None: + for squadron in self.iter_squadrons(): + squadron.populate_for_turn_0() + + def replenish(self) -> None: + for squadron in self.iter_squadrons(): + squadron.replenish_lost_pilots() + + def reset(self) -> None: + for squadron in self.iter_squadrons(): + squadron.return_all_pilots_and_aircraft() + + @property + def size(self) -> int: + return sum(len(s) for s in self.squadrons.values()) diff --git a/game/squadrons/operatingbases.py b/game/squadrons/operatingbases.py new file mode 100644 index 00000000..181e3867 --- /dev/null +++ b/game/squadrons/operatingbases.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import dataclasses +from dataclasses import dataclass + +from game.dcs.aircrafttype import AircraftType + + +@dataclass(frozen=True) +class OperatingBases: + shore: bool + carrier: bool + lha: bool + + @classmethod + def default_for_aircraft(cls, aircraft: AircraftType) -> OperatingBases: + if aircraft.dcs_unit_type.helicopter: + # Helicopters operate from anywhere by default. + return OperatingBases(shore=True, carrier=True, lha=True) + if aircraft.lha_capable: + # Marine aircraft operate from LHAs and the shore by default. + return OperatingBases(shore=True, carrier=False, lha=True) + if aircraft.carrier_capable: + # Carrier aircraft operate from carriers by default. + return OperatingBases(shore=False, carrier=True, lha=False) + # And the rest are only capable of shore operation. + return OperatingBases(shore=True, carrier=False, lha=False) + + @classmethod + def from_yaml(cls, aircraft: AircraftType, data: dict[str, bool]) -> OperatingBases: + return dataclasses.replace( + OperatingBases.default_for_aircraft(aircraft), **data + ) diff --git a/game/squadrons/pilot.py b/game/squadrons/pilot.py new file mode 100644 index 00000000..dd3bac27 --- /dev/null +++ b/game/squadrons/pilot.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import unique, Enum + +from faker import Faker + + +@dataclass +class PilotRecord: + missions_flown: int = field(default=0) + + +@unique +class PilotStatus(Enum): + Active = "Active" + OnLeave = "On leave" + Dead = "Dead" + + +@dataclass +class Pilot: + name: str + player: bool = field(default=False) + status: PilotStatus = field(default=PilotStatus.Active) + record: PilotRecord = field(default_factory=PilotRecord) + + @property + def alive(self) -> bool: + return self.status is not PilotStatus.Dead + + @property + def on_leave(self) -> bool: + return self.status is PilotStatus.OnLeave + + def send_on_leave(self) -> None: + if self.status is not PilotStatus.Active: + raise RuntimeError("Only active pilots may be sent on leave") + self.status = PilotStatus.OnLeave + + def return_from_leave(self) -> None: + if self.status is not PilotStatus.OnLeave: + raise RuntimeError("Only pilots on leave may be returned from leave") + self.status = PilotStatus.Active + + def kill(self) -> None: + self.status = PilotStatus.Dead + + @classmethod + def random(cls, faker: Faker) -> Pilot: + return Pilot(faker.name()) diff --git a/game/squadrons/squadron.py b/game/squadrons/squadron.py new file mode 100644 index 00000000..b5169d26 --- /dev/null +++ b/game/squadrons/squadron.py @@ -0,0 +1,301 @@ +from __future__ import annotations + +import logging +from collections import Iterable +from dataclasses import dataclass, field +from typing import ( + TYPE_CHECKING, + Optional, + Sequence, +) + +from faker import Faker + +from game.dcs.aircrafttype import AircraftType +from game.settings import AutoAtoBehavior, Settings +from game.squadrons.operatingbases import OperatingBases +from game.squadrons.pilot import Pilot, PilotStatus +from game.squadrons.squadrondef import SquadronDef + +if TYPE_CHECKING: + from game import Game + from game.coalition import Coalition + from gen.flights.flight import FlightType + from game.theater import ControlPoint + + +@dataclass +class Squadron: + name: str + nickname: Optional[str] + country: str + role: str + aircraft: AircraftType + livery: Optional[str] + mission_types: tuple[FlightType, ...] + operating_bases: OperatingBases + + #: The pool of pilots that have not yet been assigned to the squadron. This only + #: happens when a preset squadron defines more preset pilots than the squadron limit + #: allows. This pool will be consumed before random pilots are generated. + pilot_pool: list[Pilot] + + current_roster: list[Pilot] = field(default_factory=list, init=False, hash=False) + available_pilots: list[Pilot] = field( + default_factory=list, init=False, hash=False, compare=False + ) + + auto_assignable_mission_types: set[FlightType] = field( + init=False, hash=False, compare=False + ) + + coalition: Coalition = field(hash=False, compare=False) + settings: Settings = field(hash=False, compare=False) + + location: ControlPoint + + owned_aircraft: int = field(init=False, hash=False, compare=False, default=0) + untasked_aircraft: int = field(init=False, hash=False, compare=False, default=0) + pending_deliveries: int = field(init=False, hash=False, compare=False, default=0) + + def __post_init__(self) -> None: + self.auto_assignable_mission_types = set(self.mission_types) + + def __str__(self) -> str: + if self.nickname is None: + return self.name + return f'{self.name} "{self.nickname}"' + + def __hash__(self) -> int: + return hash( + ( + self.name, + self.nickname, + self.country, + self.role, + self.aircraft, + ) + ) + + @property + def player(self) -> bool: + return self.coalition.player + + def assign_to_base(self, base: ControlPoint) -> None: + self.location.squadrons.remove(self) + self.location = base + self.location.squadrons.append(self) + logging.debug(f"Assigned {self} to {base}") + + @property + def pilot_limits_enabled(self) -> bool: + return self.settings.enable_squadron_pilot_limits + + def set_allowed_mission_types(self, mission_types: Iterable[FlightType]) -> None: + self.mission_types = tuple(mission_types) + self.auto_assignable_mission_types.intersection_update(self.mission_types) + + def set_auto_assignable_mission_types( + self, mission_types: Iterable[FlightType] + ) -> None: + self.auto_assignable_mission_types = set(self.mission_types).intersection( + mission_types + ) + + def claim_new_pilot_if_allowed(self) -> Optional[Pilot]: + if self.pilot_limits_enabled: + return None + self._recruit_pilots(1) + return self.available_pilots.pop() + + def claim_available_pilot(self) -> Optional[Pilot]: + if not self.available_pilots: + return self.claim_new_pilot_if_allowed() + + # For opfor, so player/AI option is irrelevant. + if not self.player: + return self.available_pilots.pop() + + preference = self.settings.auto_ato_behavior + + # No preference, so the first pilot is fine. + if preference is AutoAtoBehavior.Default: + return self.available_pilots.pop() + + prefer_players = preference is AutoAtoBehavior.Prefer + for pilot in self.available_pilots: + if pilot.player == prefer_players: + self.available_pilots.remove(pilot) + return pilot + + # No pilot was found that matched the user's preference. + # + # If they chose to *never* assign players and only players remain in the pool, + # we cannot fill the slot with the available pilots. + # + # If they only *prefer* players and we're out of players, just return an AI + # pilot. + if not prefer_players: + return self.claim_new_pilot_if_allowed() + return self.available_pilots.pop() + + def claim_pilot(self, pilot: Pilot) -> None: + if pilot not in self.available_pilots: + raise ValueError( + f"Cannot assign {pilot} to {self} because they are not available" + ) + self.available_pilots.remove(pilot) + + def return_pilot(self, pilot: Pilot) -> None: + self.available_pilots.append(pilot) + + def return_pilots(self, pilots: Sequence[Pilot]) -> None: + # Return in reverse so that returning two pilots and then getting two more + # results in the same ordering. This happens commonly when resetting rosters in + # the UI, when we clear the roster because the UI is updating, then end up + # repopulating the same size flight from the same squadron. + self.available_pilots.extend(reversed(pilots)) + + def _recruit_pilots(self, count: int) -> None: + new_pilots = self.pilot_pool[:count] + self.pilot_pool = self.pilot_pool[count:] + count -= len(new_pilots) + new_pilots.extend([Pilot(self.faker.name()) for _ in range(count)]) + self.current_roster.extend(new_pilots) + self.available_pilots.extend(new_pilots) + + def populate_for_turn_0(self) -> None: + if any(p.status is not PilotStatus.Active for p in self.pilot_pool): + raise ValueError("Squadrons can only be created with active pilots.") + self._recruit_pilots(self.settings.squadron_pilot_limit) + + def replenish_lost_pilots(self) -> None: + if not self.pilot_limits_enabled: + return + + replenish_count = min( + self.settings.squadron_replenishment_rate, + self._number_of_unfilled_pilot_slots, + ) + if replenish_count > 0: + self._recruit_pilots(replenish_count) + + def return_all_pilots_and_aircraft(self) -> None: + self.available_pilots = list(self.active_pilots) + self.untasked_aircraft = self.owned_aircraft + + @staticmethod + def send_on_leave(pilot: Pilot) -> None: + pilot.send_on_leave() + + def return_from_leave(self, pilot: Pilot) -> None: + if not self.has_unfilled_pilot_slots: + raise RuntimeError( + f"Cannot return {pilot} from leave because {self} is full" + ) + pilot.return_from_leave() + + @property + def faker(self) -> Faker: + return self.coalition.faker + + def _pilots_with_status(self, status: PilotStatus) -> list[Pilot]: + return [p for p in self.current_roster if p.status == status] + + def _pilots_without_status(self, status: PilotStatus) -> list[Pilot]: + return [p for p in self.current_roster if p.status != status] + + @property + def max_size(self) -> int: + return self.settings.squadron_pilot_limit + + @property + def active_pilots(self) -> list[Pilot]: + return self._pilots_with_status(PilotStatus.Active) + + @property + def pilots_on_leave(self) -> list[Pilot]: + return self._pilots_with_status(PilotStatus.OnLeave) + + @property + def number_of_pilots_including_inactive(self) -> int: + return len(self.current_roster) + + @property + def _number_of_unfilled_pilot_slots(self) -> int: + return self.max_size - len(self.active_pilots) + + @property + def number_of_available_pilots(self) -> int: + return len(self.available_pilots) + + def can_provide_pilots(self, count: int) -> bool: + return not self.pilot_limits_enabled or self.number_of_available_pilots >= count + + @property + def has_available_pilots(self) -> bool: + return not self.pilot_limits_enabled or bool(self.available_pilots) + + @property + def has_unfilled_pilot_slots(self) -> bool: + return not self.pilot_limits_enabled or self._number_of_unfilled_pilot_slots > 0 + + def can_auto_assign(self, task: FlightType) -> bool: + return task in self.auto_assignable_mission_types + + def operates_from(self, control_point: ControlPoint) -> bool: + if control_point.is_carrier: + return self.operating_bases.carrier + elif control_point.is_lha: + return self.operating_bases.lha + else: + return self.operating_bases.shore + + def pilot_at_index(self, index: int) -> Pilot: + return self.current_roster[index] + + def claim_inventory(self, count: int) -> None: + if self.untasked_aircraft < count: + raise ValueError( + f"Cannot remove {count} from {self.name}. Only have " + f"{self.untasked_aircraft}." + ) + self.untasked_aircraft -= count + + def can_fulfill_flight(self, count: int) -> bool: + return self.can_provide_pilots(count) and self.untasked_aircraft >= count + + def refund_orders(self) -> None: + self.coalition.adjust_budget(self.aircraft.price * self.pending_deliveries) + self.pending_deliveries = 0 + + def deliver_orders(self) -> None: + self.owned_aircraft += self.pending_deliveries + self.pending_deliveries = 0 + + @property + def max_fulfillable_aircraft(self) -> int: + return max(self.number_of_available_pilots, self.untasked_aircraft) + + @classmethod + def create_from( + cls, + squadron_def: SquadronDef, + base: ControlPoint, + coalition: Coalition, + game: Game, + ) -> Squadron: + return Squadron( + squadron_def.name, + squadron_def.nickname, + squadron_def.country, + squadron_def.role, + squadron_def.aircraft, + squadron_def.livery, + squadron_def.mission_types, + squadron_def.operating_bases, + squadron_def.pilot_pool, + coalition, + game.settings, + base, + ) diff --git a/game/squadrons/squadrondef.py b/game/squadrons/squadrondef.py new file mode 100644 index 00000000..72176b34 --- /dev/null +++ b/game/squadrons/squadrondef.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +import logging +from collections import Iterable +from dataclasses import dataclass, field +from pathlib import Path +from typing import ( + TYPE_CHECKING, + Optional, +) + +import yaml + +from game.dcs.aircrafttype import AircraftType +from game.squadrons.operatingbases import OperatingBases +from game.squadrons.pilot import Pilot + +if TYPE_CHECKING: + from gen.flights.flight import FlightType + from game.theater import ControlPoint + + +@dataclass +class SquadronDef: + name: str + nickname: Optional[str] + country: str + role: str + aircraft: AircraftType + livery: Optional[str] + mission_types: tuple[FlightType, ...] + operating_bases: OperatingBases + pilot_pool: list[Pilot] + + auto_assignable_mission_types: set[FlightType] = field( + init=False, hash=False, compare=False + ) + + def __post_init__(self) -> None: + self.auto_assignable_mission_types = set(self.mission_types) + + def __str__(self) -> str: + if self.nickname is None: + return self.name + return f'{self.name} "{self.nickname}"' + + def set_allowed_mission_types(self, mission_types: Iterable[FlightType]) -> None: + self.mission_types = tuple(mission_types) + self.auto_assignable_mission_types.intersection_update(self.mission_types) + + def can_auto_assign(self, task: FlightType) -> bool: + return task in self.auto_assignable_mission_types + + def operates_from(self, control_point: ControlPoint) -> bool: + if control_point.is_carrier: + return self.operating_bases.carrier + elif control_point.is_lha: + return self.operating_bases.lha + else: + return self.operating_bases.shore + + @classmethod + def from_yaml(cls, path: Path) -> SquadronDef: + from gen.flights.ai_flight_planner_db import tasks_for_aircraft + from gen.flights.flight import FlightType + + with path.open(encoding="utf8") as squadron_file: + data = yaml.safe_load(squadron_file) + + name = data["aircraft"] + try: + unit_type = AircraftType.named(name) + except KeyError as ex: + raise KeyError(f"Could not find any aircraft named {name}") from ex + + pilots = [Pilot(n, player=False) for n in data.get("pilots", [])] + pilots.extend([Pilot(n, player=True) for n in data.get("players", [])]) + + mission_types = [FlightType.from_name(n) for n in data["mission_types"]] + tasks = tasks_for_aircraft(unit_type) + for mission_type in list(mission_types): + if mission_type not in tasks: + logging.error( + f"Squadron has mission type {mission_type} but {unit_type} is not " + f"capable of that task: {path}" + ) + mission_types.remove(mission_type) + + return SquadronDef( + name=data["name"], + nickname=data.get("nickname"), + country=data["country"], + role=data["role"], + aircraft=unit_type, + livery=data.get("livery"), + mission_types=tuple(mission_types), + operating_bases=OperatingBases.from_yaml(unit_type, data.get("bases", {})), + pilot_pool=pilots, + ) diff --git a/game/squadrons/squadrondefloader.py b/game/squadrons/squadrondefloader.py new file mode 100644 index 00000000..5af93576 --- /dev/null +++ b/game/squadrons/squadrondefloader.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import logging +from collections import defaultdict +from pathlib import Path +from typing import Iterator, Tuple, TYPE_CHECKING + +from game.dcs.aircrafttype import AircraftType +from .squadrondef import SquadronDef + +if TYPE_CHECKING: + from game import Game + from game.coalition import Coalition + + +class SquadronDefLoader: + def __init__(self, game: Game, coalition: Coalition) -> None: + self.game = game + self.coalition = coalition + + @staticmethod + def squadron_directories() -> Iterator[Path]: + from game import persistency + + yield Path(persistency.base_path()) / "Liberation/Squadrons" + yield Path("resources/squadrons") + + def load(self) -> dict[AircraftType, list[SquadronDef]]: + squadrons: dict[AircraftType, list[SquadronDef]] = defaultdict(list) + country = self.coalition.country_name + faction = self.coalition.faction + any_country = country.startswith("Combined Joint Task Forces ") + for directory in self.squadron_directories(): + for path, squadron_def in self.load_squadrons_from(directory): + if not any_country and squadron_def.country != country: + logging.debug( + "Not using squadron for non-matching country (is " + f"{squadron_def.country}, need {country}: {path}" + ) + continue + if squadron_def.aircraft not in faction.aircrafts: + logging.debug( + f"Not using squadron because {faction.name} cannot use " + f"{squadron_def.aircraft}: {path}" + ) + continue + logging.debug( + f"Found {squadron_def.name} {squadron_def.aircraft} " + f"{squadron_def.role} compatible with {faction.name}" + ) + + squadrons[squadron_def.aircraft].append(squadron_def) + return squadrons + + @staticmethod + def load_squadrons_from(directory: Path) -> Iterator[Tuple[Path, SquadronDef]]: + logging.debug(f"Looking for factions in {directory}") + # First directory level is the aircraft type so that historical squadrons that + # have flown multiple airframes can be defined as many times as needed. The main + # load() method is responsible for filtering out squadrons that aren't + # compatible with the faction. + for squadron_path in directory.glob("*/*.yaml"): + try: + yield squadron_path, SquadronDef.from_yaml(squadron_path) + except Exception as ex: + raise RuntimeError( + f"Failed to load squadron defined by {squadron_path}" + ) from ex diff --git a/game/theater/base.py b/game/theater/base.py index 02839481..a4d7568b 100644 --- a/game/theater/base.py +++ b/game/theater/base.py @@ -1,10 +1,4 @@ -import itertools -import logging -from typing import Any - -from game.dcs.aircrafttype import AircraftType from game.dcs.groundunittype import GroundUnitType -from game.dcs.unittype import UnitType BASE_MAX_STRENGTH = 1.0 BASE_MIN_STRENGTH = 0.0 @@ -12,14 +6,9 @@ BASE_MIN_STRENGTH = 0.0 class Base: def __init__(self) -> None: - self.aircraft: dict[AircraftType, int] = {} self.armor: dict[GroundUnitType, int] = {} self.strength = 1.0 - @property - def total_aircraft(self) -> int: - return sum(self.aircraft.values()) - @property def total_armor(self) -> int: return sum(self.armor.values()) @@ -31,49 +20,24 @@ class Base: total += unit_type.price * count return total - def total_units_of_type(self, unit_type: UnitType[Any]) -> int: - return sum( - [ - c - for t, c in itertools.chain(self.aircraft.items(), self.armor.items()) - if t == unit_type - ] - ) + def total_units_of_type(self, unit_type: GroundUnitType) -> int: + return sum([c for t, c in self.armor.items() if t == unit_type]) - def commission_units(self, units: dict[Any, int]) -> None: + def commission_units(self, units: dict[GroundUnitType, int]) -> None: for unit_type, unit_count in units.items(): if unit_count <= 0: continue + self.armor[unit_type] = self.armor.get(unit_type, 0) + unit_count - target_dict: dict[Any, int] - if isinstance(unit_type, AircraftType): - target_dict = self.aircraft - elif isinstance(unit_type, GroundUnitType): - target_dict = self.armor - else: - logging.error(f"Unexpected unit type of {unit_type}") - return - - target_dict[unit_type] = target_dict.get(unit_type, 0) + unit_count - - def commit_losses(self, units_lost: dict[Any, int]) -> None: + def commit_losses(self, units_lost: dict[GroundUnitType, int]) -> None: for unit_type, count in units_lost.items(): - target_dict: dict[Any, int] - if unit_type in self.aircraft: - target_dict = self.aircraft - elif unit_type in self.armor: - target_dict = self.armor - else: - print("Base didn't find event type {}".format(unit_type)) + if unit_type not in self.armor: + print("Base didn't find unit type {}".format(unit_type)) continue - if unit_type not in target_dict: - print("Base didn't find event type {}".format(unit_type)) - continue - - target_dict[unit_type] = max(target_dict[unit_type] - count, 0) - if target_dict[unit_type] == 0: - del target_dict[unit_type] + self.armor[unit_type] = max(self.armor[unit_type] - count, 0) + if self.armor[unit_type] == 0: + del self.armor[unit_type] def affect_strength(self, amount: float) -> None: self.strength += amount diff --git a/game/theater/conflicttheater.py b/game/theater/conflicttheater.py index b0a79271..30d16ec4 100644 --- a/game/theater/conflicttheater.py +++ b/game/theater/conflicttheater.py @@ -1,27 +1,11 @@ from __future__ import annotations -import itertools import math from dataclasses import dataclass -from functools import cached_property from pathlib import Path from typing import Any, Dict, Iterator, List, Optional, Tuple, TYPE_CHECKING -from dcs import Mission -from dcs.countries import ( - CombinedJointTaskForcesBlue, - CombinedJointTaskForcesRed, -) -from dcs.country import Country from dcs.mapping import Point -from dcs.planes import F_15C -from dcs.ships import ( - HandyWind, - Stennis, - USS_Arleigh_Burke_IIa, - LHA_Tarawa, -) -from dcs.statics import Fortification, Warehouse from dcs.terrain import ( caucasus, nevada, @@ -31,497 +15,23 @@ from dcs.terrain import ( thechannel, marianaislands, ) -from dcs.terrain.terrain import Airport, Terrain -from dcs.unitgroup import ( - ShipGroup, - StaticGroup, - VehicleGroup, - PlaneGroup, -) -from dcs.vehicles import AirDefence, Armor, MissilesSS, Unarmed +from dcs.terrain.terrain import Terrain from pyproj import CRS, Transformer from shapely import geometry, ops from .controlpoint import ( - Airfield, - Carrier, ControlPoint, - Fob, - Lha, MissionTarget, - OffMapSpawn, ) from .frontline import FrontLine from .landmap import Landmap, load_landmap, poly_contains from .latlon import LatLon from .projections import TransverseMercator from .seasonalconditions import SeasonalConditions -from ..point_with_heading import PointWithHeading -from ..positioned import Positioned -from ..profiling import logged_duration -from ..scenery_group import SceneryGroup -from ..utils import Distance, Heading, meters if TYPE_CHECKING: from . import TheaterGroundObject -SIZE_TINY = 150 -SIZE_SMALL = 600 -SIZE_REGULAR = 1000 -SIZE_BIG = 2000 -SIZE_LARGE = 3000 - -IMPORTANCE_LOW = 1 -IMPORTANCE_MEDIUM = 1.2 -IMPORTANCE_HIGH = 1.4 - - -class MizCampaignLoader: - BLUE_COUNTRY = CombinedJointTaskForcesBlue() - RED_COUNTRY = CombinedJointTaskForcesRed() - - OFF_MAP_UNIT_TYPE = F_15C.id - - CV_UNIT_TYPE = Stennis.id - LHA_UNIT_TYPE = LHA_Tarawa.id - FRONT_LINE_UNIT_TYPE = Armor.M_113.id - SHIPPING_LANE_UNIT_TYPE = HandyWind.id - - FOB_UNIT_TYPE = Unarmed.SKP_11.id - FARP_HELIPAD = "Invisible FARP" - - OFFSHORE_STRIKE_TARGET_UNIT_TYPE = Fortification.Oil_platform.id - SHIP_UNIT_TYPE = USS_Arleigh_Burke_IIa.id - MISSILE_SITE_UNIT_TYPE = MissilesSS.Scud_B.id - COASTAL_DEFENSE_UNIT_TYPE = MissilesSS.Hy_launcher.id - - # Multiple options for air defenses so campaign designers can more accurately see - # the coverage of their IADS for the expected type. - LONG_RANGE_SAM_UNIT_TYPES = { - AirDefence.Patriot_ln.id, - AirDefence.S_300PS_5P85C_ln.id, - AirDefence.S_300PS_5P85D_ln.id, - } - - MEDIUM_RANGE_SAM_UNIT_TYPES = { - AirDefence.Hawk_ln.id, - AirDefence.S_75M_Volhov.id, - AirDefence._5p73_s_125_ln.id, - } - - SHORT_RANGE_SAM_UNIT_TYPES = { - AirDefence.M1097_Avenger.id, - AirDefence.Rapier_fsa_launcher.id, - AirDefence._2S6_Tunguska.id, - AirDefence.Strela_1_9P31.id, - } - - AAA_UNIT_TYPES = { - AirDefence.Flak18.id, - AirDefence.Vulcan.id, - AirDefence.ZSU_23_4_Shilka.id, - } - - EWR_UNIT_TYPE = AirDefence._1L13_EWR.id - - ARMOR_GROUP_UNIT_TYPE = Armor.M_1_Abrams.id - - FACTORY_UNIT_TYPE = Fortification.Workshop_A.id - - AMMUNITION_DEPOT_UNIT_TYPE = Warehouse._Ammunition_depot.id - - STRIKE_TARGET_UNIT_TYPE = Fortification.Tech_combine.id - - def __init__(self, miz: Path, theater: ConflictTheater) -> None: - self.theater = theater - self.mission = Mission() - with logged_duration("Loading miz"): - self.mission.load_file(str(miz)) - self.control_point_id = itertools.count(1000) - - # If there are no red carriers there usually aren't red units. Make sure - # both countries are initialized so we don't have to deal with None. - if self.mission.country(self.BLUE_COUNTRY.name) is None: - self.mission.coalition["blue"].add_country(self.BLUE_COUNTRY) - if self.mission.country(self.RED_COUNTRY.name) is None: - self.mission.coalition["red"].add_country(self.RED_COUNTRY) - - @staticmethod - def control_point_from_airport(airport: Airport) -> ControlPoint: - - # The wiki says this is a legacy property and to just use regular. - size = SIZE_REGULAR - - # The importance is taken from the periodicity of the airport's - # warehouse divided by 10. 30 is the default, and out of range (valid - # values are between 1.0 and 1.4). If it is used, pick the default - # importance. - if airport.periodicity == 30: - importance = IMPORTANCE_MEDIUM - else: - importance = airport.periodicity / 10 - - cp = Airfield(airport, size, importance) - cp.captured = airport.is_blue() - - # Use the unlimited aircraft option to determine if an airfield should - # be owned by the player when the campaign is "inverted". - cp.captured_invert = airport.unlimited_aircrafts - - return cp - - def country(self, blue: bool) -> Country: - country = self.mission.country( - self.BLUE_COUNTRY.name if blue else self.RED_COUNTRY.name - ) - # Should be guaranteed because we initialized them. - assert country - return country - - @property - def blue(self) -> Country: - return self.country(blue=True) - - @property - def red(self) -> Country: - return self.country(blue=False) - - def off_map_spawns(self, blue: bool) -> Iterator[PlaneGroup]: - for group in self.country(blue).plane_group: - if group.units[0].type == self.OFF_MAP_UNIT_TYPE: - yield group - - def carriers(self, blue: bool) -> Iterator[ShipGroup]: - for group in self.country(blue).ship_group: - if group.units[0].type == self.CV_UNIT_TYPE: - yield group - - def lhas(self, blue: bool) -> Iterator[ShipGroup]: - for group in self.country(blue).ship_group: - if group.units[0].type == self.LHA_UNIT_TYPE: - yield group - - def fobs(self, blue: bool) -> Iterator[VehicleGroup]: - for group in self.country(blue).vehicle_group: - if group.units[0].type == self.FOB_UNIT_TYPE: - yield group - - @property - def ships(self) -> Iterator[ShipGroup]: - for group in self.red.ship_group: - if group.units[0].type == self.SHIP_UNIT_TYPE: - yield group - - @property - def offshore_strike_targets(self) -> Iterator[StaticGroup]: - for group in self.red.static_group: - if group.units[0].type == self.OFFSHORE_STRIKE_TARGET_UNIT_TYPE: - yield group - - @property - def missile_sites(self) -> Iterator[VehicleGroup]: - for group in self.red.vehicle_group: - if group.units[0].type == self.MISSILE_SITE_UNIT_TYPE: - yield group - - @property - def coastal_defenses(self) -> Iterator[VehicleGroup]: - for group in self.red.vehicle_group: - if group.units[0].type == self.COASTAL_DEFENSE_UNIT_TYPE: - yield group - - @property - def long_range_sams(self) -> Iterator[VehicleGroup]: - for group in self.red.vehicle_group: - if group.units[0].type in self.LONG_RANGE_SAM_UNIT_TYPES: - yield group - - @property - def medium_range_sams(self) -> Iterator[VehicleGroup]: - for group in self.red.vehicle_group: - if group.units[0].type in self.MEDIUM_RANGE_SAM_UNIT_TYPES: - yield group - - @property - def short_range_sams(self) -> Iterator[VehicleGroup]: - for group in self.red.vehicle_group: - if group.units[0].type in self.SHORT_RANGE_SAM_UNIT_TYPES: - yield group - - @property - def aaa(self) -> Iterator[VehicleGroup]: - for group in itertools.chain(self.blue.vehicle_group, self.red.vehicle_group): - if group.units[0].type in self.AAA_UNIT_TYPES: - yield group - - @property - def ewrs(self) -> Iterator[VehicleGroup]: - for group in self.red.vehicle_group: - if group.units[0].type in self.EWR_UNIT_TYPE: - yield group - - @property - def armor_groups(self) -> Iterator[VehicleGroup]: - for group in itertools.chain(self.blue.vehicle_group, self.red.vehicle_group): - if group.units[0].type in self.ARMOR_GROUP_UNIT_TYPE: - yield group - - @property - def helipads(self) -> Iterator[StaticGroup]: - for group in self.blue.static_group: - if group.units[0].type == self.FARP_HELIPAD: - yield group - - @property - def factories(self) -> Iterator[StaticGroup]: - for group in self.blue.static_group: - if group.units[0].type in self.FACTORY_UNIT_TYPE: - yield group - - @property - def ammunition_depots(self) -> Iterator[StaticGroup]: - for group in itertools.chain(self.blue.static_group, self.red.static_group): - if group.units[0].type in self.AMMUNITION_DEPOT_UNIT_TYPE: - yield group - - @property - def strike_targets(self) -> Iterator[StaticGroup]: - for group in itertools.chain(self.blue.static_group, self.red.static_group): - if group.units[0].type in self.STRIKE_TARGET_UNIT_TYPE: - yield group - - @property - def scenery(self) -> List[SceneryGroup]: - return SceneryGroup.from_trigger_zones(self.mission.triggers._zones) - - @cached_property - def control_points(self) -> Dict[int, ControlPoint]: - control_points = {} - for airport in self.mission.terrain.airport_list(): - if airport.is_blue() or airport.is_red(): - control_point = self.control_point_from_airport(airport) - control_points[control_point.id] = control_point - - for blue in (False, True): - for group in self.off_map_spawns(blue): - control_point = OffMapSpawn( - next(self.control_point_id), str(group.name), group.position - ) - control_point.captured = blue - control_point.captured_invert = group.late_activation - control_points[control_point.id] = control_point - for ship in self.carriers(blue): - # TODO: Name the carrier. - control_point = Carrier( - "carrier", ship.position, next(self.control_point_id) - ) - control_point.captured = blue - control_point.captured_invert = ship.late_activation - control_points[control_point.id] = control_point - for ship in self.lhas(blue): - # TODO: Name the LHA.db - control_point = Lha("lha", ship.position, next(self.control_point_id)) - control_point.captured = blue - control_point.captured_invert = ship.late_activation - control_points[control_point.id] = control_point - for fob in self.fobs(blue): - control_point = Fob( - str(fob.name), fob.position, next(self.control_point_id) - ) - control_point.captured = blue - control_point.captured_invert = fob.late_activation - control_points[control_point.id] = control_point - - return control_points - - @property - def front_line_path_groups(self) -> Iterator[VehicleGroup]: - for group in self.country(blue=True).vehicle_group: - if group.units[0].type == self.FRONT_LINE_UNIT_TYPE: - yield group - - @property - def shipping_lane_groups(self) -> Iterator[ShipGroup]: - for group in self.country(blue=True).ship_group: - if group.units[0].type == self.SHIPPING_LANE_UNIT_TYPE: - yield group - - def add_supply_routes(self) -> None: - for group in self.front_line_path_groups: - # The unit will have its first waypoint at the source CP and the final - # waypoint at the destination CP. Each waypoint defines the path of the - # cargo ship. - waypoints = [p.position for p in group.points] - origin = self.theater.closest_control_point(waypoints[0]) - if origin is None: - raise RuntimeError( - f"No control point near the first waypoint of {group.name}" - ) - destination = self.theater.closest_control_point(waypoints[-1]) - if destination is None: - raise RuntimeError( - f"No control point near the final waypoint of {group.name}" - ) - - self.control_points[origin.id].create_convoy_route(destination, waypoints) - self.control_points[destination.id].create_convoy_route( - origin, list(reversed(waypoints)) - ) - - def add_shipping_lanes(self) -> None: - for group in self.shipping_lane_groups: - # The unit will have its first waypoint at the source CP and the final - # waypoint at the destination CP. Each waypoint defines the path of the - # cargo ship. - waypoints = [p.position for p in group.points] - origin = self.theater.closest_control_point(waypoints[0]) - if origin is None: - raise RuntimeError( - f"No control point near the first waypoint of {group.name}" - ) - destination = self.theater.closest_control_point(waypoints[-1]) - if destination is None: - raise RuntimeError( - f"No control point near the final waypoint of {group.name}" - ) - - self.control_points[origin.id].create_shipping_lane(destination, waypoints) - self.control_points[destination.id].create_shipping_lane( - origin, list(reversed(waypoints)) - ) - - def objective_info( - self, near: Positioned, allow_naval: bool = False - ) -> Tuple[ControlPoint, Distance]: - closest = self.theater.closest_control_point(near.position, allow_naval) - distance = meters(closest.position.distance_to_point(near.position)) - return closest, distance - - def add_preset_locations(self) -> None: - for static in self.offshore_strike_targets: - closest, distance = self.objective_info(static) - closest.preset_locations.offshore_strike_locations.append( - PointWithHeading.from_point( - static.position, Heading.from_degrees(static.units[0].heading) - ) - ) - - for ship in self.ships: - closest, distance = self.objective_info(ship, allow_naval=True) - closest.preset_locations.ships.append( - PointWithHeading.from_point( - ship.position, Heading.from_degrees(ship.units[0].heading) - ) - ) - - for group in self.missile_sites: - closest, distance = self.objective_info(group) - closest.preset_locations.missile_sites.append( - PointWithHeading.from_point( - group.position, Heading.from_degrees(group.units[0].heading) - ) - ) - - for group in self.coastal_defenses: - closest, distance = self.objective_info(group) - closest.preset_locations.coastal_defenses.append( - PointWithHeading.from_point( - group.position, Heading.from_degrees(group.units[0].heading) - ) - ) - - for group in self.long_range_sams: - closest, distance = self.objective_info(group) - closest.preset_locations.long_range_sams.append( - PointWithHeading.from_point( - group.position, Heading.from_degrees(group.units[0].heading) - ) - ) - - for group in self.medium_range_sams: - closest, distance = self.objective_info(group) - closest.preset_locations.medium_range_sams.append( - PointWithHeading.from_point( - group.position, Heading.from_degrees(group.units[0].heading) - ) - ) - - for group in self.short_range_sams: - closest, distance = self.objective_info(group) - closest.preset_locations.short_range_sams.append( - PointWithHeading.from_point( - group.position, Heading.from_degrees(group.units[0].heading) - ) - ) - - for group in self.aaa: - closest, distance = self.objective_info(group) - closest.preset_locations.aaa.append( - PointWithHeading.from_point( - group.position, Heading.from_degrees(group.units[0].heading) - ) - ) - - for group in self.ewrs: - closest, distance = self.objective_info(group) - closest.preset_locations.ewrs.append( - PointWithHeading.from_point( - group.position, Heading.from_degrees(group.units[0].heading) - ) - ) - - for group in self.armor_groups: - closest, distance = self.objective_info(group) - closest.preset_locations.armor_groups.append( - PointWithHeading.from_point( - group.position, Heading.from_degrees(group.units[0].heading) - ) - ) - - for static in self.helipads: - closest, distance = self.objective_info(static) - closest.helipads.append( - PointWithHeading.from_point( - static.position, Heading.from_degrees(static.units[0].heading) - ) - ) - - for static in self.factories: - closest, distance = self.objective_info(static) - closest.preset_locations.factories.append( - PointWithHeading.from_point( - static.position, Heading.from_degrees(static.units[0].heading) - ) - ) - - for static in self.ammunition_depots: - closest, distance = self.objective_info(static) - closest.preset_locations.ammunition_depots.append( - PointWithHeading.from_point( - static.position, Heading.from_degrees(static.units[0].heading) - ) - ) - - for static in self.strike_targets: - closest, distance = self.objective_info(static) - closest.preset_locations.strike_locations.append( - PointWithHeading.from_point( - static.position, Heading.from_degrees(static.units[0].heading) - ) - ) - - for scenery_group in self.scenery: - closest, distance = self.objective_info(scenery_group) - closest.preset_locations.scenery.append(scenery_group) - - def populate_theater(self) -> None: - for control_point in self.control_points.values(): - self.theater.add_controlpoint(control_point) - self.add_preset_locations() - self.add_supply_routes() - self.add_shipping_lanes() - - @dataclass class ReferencePoint: world_coordinates: Point @@ -730,29 +240,11 @@ class ConflictTheater: return i raise KeyError(f"Cannot find ControlPoint with ID {id}") - @staticmethod - def from_json(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 + def control_point_named(self, name: str) -> ControlPoint: + for cp in self.controlpoints: + if cp.name == name: + return cp + raise KeyError(f"Cannot find ControlPoint named {name}") @property def seasonal_conditions(self) -> SeasonalConditions: @@ -779,7 +271,7 @@ class CaucasusTheater(ConflictTheater): ReferencePoint(caucasus.Batumi.position, Point(1307, 1205)), ) - landmap = load_landmap("resources\\caulandmap.p") + landmap = load_landmap(Path("resources/caulandmap.p")) daytime_map = { "dawn": (6, 9), "day": (9, 18), @@ -807,7 +299,7 @@ class PersianGulfTheater(ConflictTheater): ReferencePoint(persiangulf.Jiroft.position, Point(1692, 1343)), ReferencePoint(persiangulf.Liwa_AFB.position, Point(358, 3238)), ) - landmap = load_landmap("resources\\gulflandmap.p") + landmap = load_landmap(Path("resources/gulflandmap.p")) daytime_map = { "dawn": (6, 8), "day": (8, 16), @@ -835,7 +327,7 @@ class NevadaTheater(ConflictTheater): ReferencePoint(nevada.Mina_Airport_3Q0.position, Point(252, 295)), ReferencePoint(nevada.Laughlin_Airport.position, Point(844, 909)), ) - landmap = load_landmap("resources\\nevlandmap.p") + landmap = load_landmap(Path("resources/nevlandmap.p")) daytime_map = { "dawn": (4, 6), "day": (6, 17), @@ -863,7 +355,7 @@ class NormandyTheater(ConflictTheater): ReferencePoint(normandy.Needs_Oar_Point.position, Point(515, 329)), ReferencePoint(normandy.Evreux.position, Point(2029, 1709)), ) - landmap = load_landmap("resources\\normandylandmap.p") + landmap = load_landmap(Path("resources/normandylandmap.p")) daytime_map = { "dawn": (6, 8), "day": (10, 17), @@ -891,7 +383,7 @@ class TheChannelTheater(ConflictTheater): ReferencePoint(thechannel.Abbeville_Drucat.position, Point(2005, 2390)), ReferencePoint(thechannel.Detling.position, Point(706, 382)), ) - landmap = load_landmap("resources\\channellandmap.p") + landmap = load_landmap(Path("resources/channellandmap.p")) daytime_map = { "dawn": (6, 8), "day": (10, 17), @@ -919,7 +411,7 @@ class SyriaTheater(ConflictTheater): ReferencePoint(syria.Eyn_Shemer.position, Point(564, 1289)), ReferencePoint(syria.Tabqa.position, Point(1329, 491)), ) - landmap = load_landmap("resources\\syrialandmap.p") + landmap = load_landmap(Path("resources/syrialandmap.p")) daytime_map = { "dawn": (6, 8), "day": (8, 16), @@ -944,7 +436,7 @@ class MarianaIslandsTheater(ConflictTheater): terrain = marianaislands.MarianaIslands() overview_image = "marianaislands.gif" - landmap = load_landmap("resources\\marianaislandslandmap.p") + landmap = load_landmap(Path("resources/marianaislandslandmap.p")) daytime_map = { "dawn": (6, 8), "day": (8, 16), diff --git a/game/theater/controlpoint.py b/game/theater/controlpoint.py index 472c2449..a7c71a52 100644 --- a/game/theater/controlpoint.py +++ b/game/theater/controlpoint.py @@ -55,6 +55,7 @@ from ..weather import Conditions if TYPE_CHECKING: from game import Game from gen.flights.flight import FlightType + from game.squadrons.squadron import Squadron from ..transfers import PendingTransfers FREE_FRONTLINE_UNIT_SUPPLY: int = 15 @@ -294,8 +295,6 @@ class ControlPoint(MissionTarget, ABC): name: str, position: Point, at: db.StartingPosition, - size: int, - importance: float, has_frontline: bool = True, cptype: ControlPointType = ControlPointType.AIRBASE, ) -> None: @@ -308,9 +307,6 @@ class ControlPoint(MissionTarget, ABC): self.preset_locations = PresetLocations() self.helipads: List[PointWithHeading] = [] - # TODO: Should be Airbase specific. - self.size = size - self.importance = importance self.captured = False self.captured_invert = False # TODO: Should be Airbase specific. @@ -322,12 +318,14 @@ class ControlPoint(MissionTarget, ABC): self.cptype = cptype # TODO: Should be Airbase specific. self.stances: Dict[int, CombatStance] = {} - from ..unitdelivery import PendingUnitDeliveries + from ..groundunitorders import GroundUnitOrders - self.pending_unit_deliveries = PendingUnitDeliveries(self) + self.ground_unit_orders = GroundUnitOrders(self) self.target_position: Optional[Point] = None + self.squadrons: list[Squadron] = [] + def __repr__(self) -> str: return f"<{self.__class__}: {self.name}>" @@ -588,25 +586,14 @@ class ControlPoint(MissionTarget, ABC): return airbase return None - def _retreat_air_units( - self, game: Game, airframe: AircraftType, count: int - ) -> None: - while count: - logging.debug(f"Retreating {count} {airframe} from {self.name}") - destination = self.aircraft_retreat_destination(game, airframe) - if destination is None: - self.capture_aircraft(game, airframe, count) - return - parking = destination.unclaimed_parking(game) - transfer_amount = min([parking, count]) - destination.base.commission_units({airframe: transfer_amount}) - count -= transfer_amount + @staticmethod + def _retreat_squadron(squadron: Squadron) -> None: + logging.error("Air unit retreat not currently implemented") def retreat_air_units(self, game: Game) -> None: # TODO: Capture in order of price to retain maximum value? - while self.base.aircraft: - airframe, count = self.base.aircraft.popitem() - self._retreat_air_units(game, airframe, count) + for squadron in self.squadrons: + self._retreat_squadron(squadron) def depopulate_uncapturable_tgos(self) -> None: for tgo in self.connected_objectives: @@ -615,7 +602,10 @@ class ControlPoint(MissionTarget, ABC): # TODO: Should be Airbase specific. def capture(self, game: Game, for_player: bool) -> None: - self.pending_unit_deliveries.refund_all(game.coalition_for(for_player)) + coalition = game.coalition_for(for_player) + self.ground_unit_orders.refund_all(coalition) + for squadron in self.squadrons: + squadron.refund_orders() self.retreat_ground_units(game) self.retreat_air_units(game) self.depopulate_uncapturable_tgos() @@ -631,19 +621,6 @@ class ControlPoint(MissionTarget, ABC): def can_operate(self, aircraft: AircraftType) -> bool: ... - def aircraft_transferring(self, game: Game) -> dict[AircraftType, int]: - ato = game.coalition_for(self.captured).ato - transferring: defaultdict[AircraftType, int] = defaultdict(int) - for package in ato.packages: - for flight in package.flights: - if flight.departure == flight.arrival: - continue - if flight.departure == self: - transferring[flight.unit_type] -= flight.count - elif flight.arrival == self: - transferring[flight.unit_type] += flight.count - return transferring - def unclaimed_parking(self, game: Game) -> int: return self.total_aircraft_parking - self.allocated_aircraft(game).total @@ -673,7 +650,9 @@ class ControlPoint(MissionTarget, ABC): self.runway_status.begin_repair() def process_turn(self, game: Game) -> None: - self.pending_unit_deliveries.process(game) + self.ground_unit_orders.process(game) + for squadron in self.squadrons: + squadron.deliver_orders() runway_status = self.runway_status if runway_status is not None: @@ -695,21 +674,22 @@ class ControlPoint(MissionTarget, ABC): u.position.x = u.position.x + delta.x u.position.y = u.position.y + delta.y - def allocated_aircraft(self, game: Game) -> AircraftAllocations: - on_order = {} - for unit_bought, count in self.pending_unit_deliveries.units.items(): - if isinstance(unit_bought, AircraftType): - on_order[unit_bought] = count + def allocated_aircraft(self, _game: Game) -> AircraftAllocations: + present: dict[AircraftType, int] = defaultdict(int) + on_order: dict[AircraftType, int] = defaultdict(int) + for squadron in self.squadrons: + present[squadron.aircraft] += squadron.owned_aircraft + # TODO: Only if this is the squadron destination, not location. + on_order[squadron.aircraft] += squadron.pending_deliveries - return AircraftAllocations( - self.base.aircraft, on_order, self.aircraft_transferring(game) - ) + # TODO: Implement squadron transfers. + return AircraftAllocations(present, on_order, transferring={}) def allocated_ground_units( self, transfers: PendingTransfers ) -> GroundUnitAllocations: on_order = {} - for unit_bought, count in self.pending_unit_deliveries.units.items(): + for unit_bought, count in self.ground_unit_orders.units.items(): if isinstance(unit_bought, GroundUnitType): on_order[unit_bought] = count @@ -815,16 +795,12 @@ class ControlPoint(MissionTarget, ABC): class Airfield(ControlPoint): - def __init__( - self, airport: Airport, size: int, importance: float, has_frontline: bool = True - ) -> None: + def __init__(self, airport: Airport, has_frontline: bool = True) -> None: super().__init__( airport.id, airport.name, airport.position, airport, - size, - importance, has_frontline, cptype=ControlPointType.AIRBASE, ) @@ -990,15 +966,11 @@ class NavalControlPoint(ControlPoint, ABC): class Carrier(NavalControlPoint): def __init__(self, name: str, at: Point, cp_id: int): - import game.theater.conflicttheater - super().__init__( cp_id, name, at, at, - game.theater.conflicttheater.SIZE_SMALL, - 1, has_frontline=False, cptype=ControlPointType.AIRCRAFT_CARRIER_GROUP, ) @@ -1034,15 +1006,11 @@ class Carrier(NavalControlPoint): class Lha(NavalControlPoint): def __init__(self, name: str, at: Point, cp_id: int): - import game.theater.conflicttheater - super().__init__( cp_id, name, at, at, - game.theater.conflicttheater.SIZE_SMALL, - 1, has_frontline=False, cptype=ControlPointType.LHA_GROUP, ) @@ -1071,15 +1039,11 @@ class OffMapSpawn(ControlPoint): return True def __init__(self, cp_id: int, name: str, position: Point): - from . import IMPORTANCE_MEDIUM, SIZE_REGULAR - super().__init__( cp_id, name, position, at=position, - size=SIZE_REGULAR, - importance=IMPORTANCE_MEDIUM, has_frontline=False, cptype=ControlPointType.OFF_MAP, ) @@ -1128,15 +1092,11 @@ class OffMapSpawn(ControlPoint): class Fob(ControlPoint): def __init__(self, name: str, at: Point, cp_id: int): - import game.theater.conflicttheater - super().__init__( cp_id, name, at, at, - game.theater.conflicttheater.SIZE_SMALL, - 1, has_frontline=True, cptype=ControlPointType.FOB, ) diff --git a/game/theater/landmap.py b/game/theater/landmap.py index 2cc3867c..8192b057 100644 --- a/game/theater/landmap.py +++ b/game/theater/landmap.py @@ -3,6 +3,7 @@ import pickle from functools import cached_property from typing import Optional, Tuple, Union import logging +from pathlib import Path from shapely import geometry from shapely.geometry import MultiPolygon, Polygon @@ -27,7 +28,7 @@ class Landmap: return self.inclusion_zones - self.exclusion_zones - self.sea_zones -def load_landmap(filename: str) -> Optional[Landmap]: +def load_landmap(filename: Path) -> Optional[Landmap]: try: with open(filename, "rb") as f: return pickle.load(f) diff --git a/game/theater/seasonalconditions/caucasus.py b/game/theater/seasonalconditions/caucasus.py index e605a543..2fe87861 100644 --- a/game/theater/seasonalconditions/caucasus.py +++ b/game/theater/seasonalconditions/caucasus.py @@ -23,8 +23,8 @@ CONDITIONS = SeasonalConditions( Season.Summer: WeatherTypeChances( thunderstorm=1, raining=10, - cloudy=30, - clear_skies=60, + cloudy=35, + clear_skies=55, ), Season.Fall: WeatherTypeChances( thunderstorm=1, diff --git a/game/theater/seasonalconditions/nevada.py b/game/theater/seasonalconditions/nevada.py index 352ca456..36b7934d 100644 --- a/game/theater/seasonalconditions/nevada.py +++ b/game/theater/seasonalconditions/nevada.py @@ -23,8 +23,8 @@ CONDITIONS = SeasonalConditions( Season.Summer: WeatherTypeChances( thunderstorm=1, raining=5, - cloudy=25, - clear_skies=70, + cloudy=30, + clear_skies=65, ), Season.Fall: WeatherTypeChances( thunderstorm=1, diff --git a/game/theater/seasonalconditions/normandy.py b/game/theater/seasonalconditions/normandy.py index 109c781f..a0f86b6d 100644 --- a/game/theater/seasonalconditions/normandy.py +++ b/game/theater/seasonalconditions/normandy.py @@ -23,8 +23,8 @@ CONDITIONS = SeasonalConditions( Season.Summer: WeatherTypeChances( thunderstorm=1, raining=10, - cloudy=30, - clear_skies=60, + cloudy=35, + clear_skies=55, ), Season.Fall: WeatherTypeChances( thunderstorm=1, diff --git a/game/theater/seasonalconditions/persiangulf.py b/game/theater/seasonalconditions/persiangulf.py index 467168ab..923b887d 100644 --- a/game/theater/seasonalconditions/persiangulf.py +++ b/game/theater/seasonalconditions/persiangulf.py @@ -12,26 +12,26 @@ CONDITIONS = SeasonalConditions( # Winter there is some rain in PG (Dubai) thunderstorm=1, raining=15, - cloudy=35, - clear_skies=50, + cloudy=40, + clear_skies=45, ), Season.Spring: WeatherTypeChances( thunderstorm=1, raining=2, - cloudy=18, - clear_skies=80, + cloudy=28, + clear_skies=70, ), Season.Summer: WeatherTypeChances( thunderstorm=1, raining=1, - cloudy=8, - clear_skies=90, + cloudy=18, + clear_skies=80, ), Season.Fall: WeatherTypeChances( thunderstorm=1, raining=2, - cloudy=18, - clear_skies=80, + cloudy=28, + clear_skies=70, ), }, ) diff --git a/game/theater/seasonalconditions/syria.py b/game/theater/seasonalconditions/syria.py index 0a6c7ec1..d405abc8 100644 --- a/game/theater/seasonalconditions/syria.py +++ b/game/theater/seasonalconditions/syria.py @@ -11,8 +11,8 @@ CONDITIONS = SeasonalConditions( Season.Winter: WeatherTypeChances( thunderstorm=1, raining=25, - cloudy=25, - clear_skies=50, + cloudy=35, + clear_skies=40, ), Season.Spring: WeatherTypeChances( thunderstorm=1, @@ -22,15 +22,15 @@ CONDITIONS = SeasonalConditions( ), Season.Summer: WeatherTypeChances( thunderstorm=1, - raining=3, - cloudy=20, - clear_skies=77, + raining=5, + cloudy=30, + clear_skies=65, ), Season.Fall: WeatherTypeChances( thunderstorm=1, - raining=10, - cloudy=30, - clear_skies=60, + raining=15, + cloudy=35, + clear_skies=50, ), }, ) diff --git a/game/theater/seasonalconditions/thechannel.py b/game/theater/seasonalconditions/thechannel.py index 109c781f..a0f86b6d 100644 --- a/game/theater/seasonalconditions/thechannel.py +++ b/game/theater/seasonalconditions/thechannel.py @@ -23,8 +23,8 @@ CONDITIONS = SeasonalConditions( Season.Summer: WeatherTypeChances( thunderstorm=1, raining=10, - cloudy=30, - clear_skies=60, + cloudy=35, + clear_skies=55, ), Season.Fall: WeatherTypeChances( thunderstorm=1, diff --git a/game/theater/start_generator.py b/game/theater/start_generator.py index 61cc25af..1281490d 100644 --- a/game/theater/start_generator.py +++ b/game/theater/start_generator.py @@ -30,7 +30,7 @@ from game.theater.theatergroundobject import ( ) from game.utils import Heading from game.version import VERSION -from gen import namegen +from gen.naming import namegen from gen.coastal.coastal_group_generator import generate_coastal_group from gen.defenses.armor_group_generator import generate_armor_group from gen.fleet.ship_group_generator import ( @@ -49,22 +49,12 @@ from . import ( Fob, OffMapSpawn, ) +from ..campaignloader.campaignairwingconfig import CampaignAirWingConfig from ..profiling import logged_duration 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: @@ -96,6 +86,7 @@ class GameGenerator: player: Faction, enemy: Faction, theater: ConflictTheater, + air_wing_config: CampaignAirWingConfig, settings: Settings, generator_settings: GeneratorSettings, mod_settings: ModSettings, @@ -103,6 +94,7 @@ class GameGenerator: self.player = player self.enemy = enemy self.theater = theater + self.air_wing_config = air_wing_config self.settings = settings self.generator_settings = generator_settings self.mod_settings = mod_settings @@ -116,6 +108,7 @@ class GameGenerator: player_faction=self.player.apply_mod_settings(self.mod_settings), enemy_faction=self.enemy.apply_mod_settings(self.mod_settings), theater=self.theater, + air_wing_config=self.air_wing_config, start_date=self.generator_settings.start_date, settings=self.settings, player_budget=self.generator_settings.player_budget, diff --git a/game/transfers.py b/game/transfers.py index ffb879e4..3a8d62f6 100644 --- a/game/transfers.py +++ b/game/transfers.py @@ -51,7 +51,6 @@ from dcs.mapping import Point from game.dcs.aircrafttype import AircraftType from game.dcs.groundunittype import GroundUnitType from game.procurement import AircraftProcurementRequest -from game.squadrons import Squadron from game.theater import ControlPoint, MissionTarget from game.theater.transitnetwork import ( TransitConnection, @@ -67,7 +66,7 @@ from gen.naming import namegen if TYPE_CHECKING: from game import Game - from game.inventory import ControlPointAircraftInventory + from game.squadrons import Squadron class Transport: @@ -315,29 +314,20 @@ class AirliftPlanner: if cp.captured != self.for_player: continue - inventory = self.game.aircraft_inventory.for_control_point(cp) - for unit_type, available in inventory.all_aircraft: - squadrons = air_wing.auto_assignable_for_task_with_type( - unit_type, FlightType.TRANSPORT - ) - for squadron in squadrons: - if self.compatible_with_mission(unit_type, cp): - while ( - available - and squadron.has_available_pilots - and self.transfer.transport is None - ): - flight_size = self.create_airlift_flight( - squadron, inventory - ) - available -= flight_size + squadrons = air_wing.auto_assignable_for_task_at(FlightType.TRANSPORT, cp) + for squadron in squadrons: + if self.compatible_with_mission(squadron.aircraft, cp): + while ( + squadron.untasked_aircraft + and squadron.has_available_pilots + and self.transfer.transport is None + ): + self.create_airlift_flight(squadron) if self.package.flights: self.game.ato_for(self.for_player).add_package(self.package) - def create_airlift_flight( - self, squadron: Squadron, inventory: ControlPointAircraftInventory - ) -> int: - available_aircraft = inventory.available(squadron.aircraft) + def create_airlift_flight(self, squadron: Squadron) -> int: + available_aircraft = squadron.untasked_aircraft capacity_each = 1 if squadron.aircraft.dcs_unit_type.helicopter else 2 required = math.ceil(self.transfer.size / capacity_each) flight_size = min( @@ -348,8 +338,8 @@ class AirliftPlanner: # TODO: Use number_of_available_pilots directly once feature flag is gone. # The number of currently available pilots is not relevant when pilot limits # are disabled. - if not squadron.can_provide_pilots(flight_size): - flight_size = squadron.number_of_available_pilots + if not squadron.can_fulfill_flight(flight_size): + flight_size = squadron.max_fulfillable_aircraft capacity = flight_size * capacity_each if capacity < self.transfer.size: @@ -359,16 +349,15 @@ class AirliftPlanner: else: transfer = self.transfer - player = inventory.control_point.captured flight = Flight( self.package, - self.game.country_for(player), + self.game.country_for(squadron.player), squadron, flight_size, FlightType.TRANSPORT, self.game.settings.default_start_type, - departure=inventory.control_point, - arrival=inventory.control_point, + departure=squadron.location, + arrival=squadron.location, divert=None, cargo=transfer, ) @@ -381,7 +370,6 @@ class AirliftPlanner: self.package, self.game.coalition_for(self.for_player), self.game.theater ) planner.populate_flight_plan(flight) - self.game.aircraft_inventory.claim_for_flight(flight) return flight_size @@ -652,8 +640,7 @@ class PendingTransfers: flight.package.remove_flight(flight) if not flight.package.flights: self.game.ato_for(self.player).remove_package(flight.package) - self.game.aircraft_inventory.return_from_flight(flight) - flight.clear_roster() + flight.return_pilots_and_aircraft() @cancel_transport.register def _cancel_transport_convoy( @@ -722,26 +709,59 @@ class PendingTransfers: ): self.order_airlift_assets_at(control_point) - @staticmethod - def desired_airlift_capacity(control_point: ControlPoint) -> int: - return 4 if control_point.has_factory else 0 + def desired_airlift_capacity(self, control_point: ControlPoint) -> int: - def current_airlift_capacity(self, control_point: ControlPoint) -> int: - inventory = self.game.aircraft_inventory.for_control_point(control_point) - squadrons = self.game.air_wing_for( - control_point.captured - ).auto_assignable_for_task(FlightType.TRANSPORT) - unit_types = {s.aircraft for s in squadrons} + if control_point.has_factory: + is_major_hub = control_point.total_aircraft_parking > 0 + # Check if there is a CP which is only reachable via Airlift + transit_network = self.network_for(control_point) + for cp in self.game.theater.control_points_for(self.player): + # check if the CP has no factory, is reachable from the current + # position and can only be reached with airlift connections + if ( + cp.can_deploy_ground_units + and not cp.has_factory + and transit_network.has_link(control_point, cp) + and not any( + link_type + for link, link_type in transit_network.nodes[cp].items() + if not link_type == TransitConnection.Airlift + ) + ): + return 4 + + if ( + is_major_hub + and cp.has_factory + and cp.total_aircraft_parking > control_point.total_aircraft_parking + ): + is_major_hub = False + + if is_major_hub: + # If the current CP is a major hub keep always 2 planes on reserve + return 2 + + return 0 + + @staticmethod + def current_airlift_capacity(control_point: ControlPoint) -> int: return sum( - count - for unit_type, count in inventory.all_aircraft - if unit_type in unit_types + s.owned_aircraft + for s in control_point.squadrons + if s.can_auto_assign(FlightType.TRANSPORT) ) def order_airlift_assets_at(self, control_point: ControlPoint) -> None: - gap = self.desired_airlift_capacity( - control_point - ) - self.current_airlift_capacity(control_point) + unclaimed_parking = control_point.unclaimed_parking(self.game) + # Buy a maximum of unclaimed_parking only to prevent that aircraft procurement + # take place at another base + gap = min( + [ + self.desired_airlift_capacity(control_point) + - self.current_airlift_capacity(control_point), + unclaimed_parking, + ] + ) if gap <= 0: return @@ -751,6 +771,10 @@ class PendingTransfers: # aesthetic. gap += 1 + if gap > unclaimed_parking: + # Prevent to buy more aircraft than possible + return + self.game.coalition_for(self.player).add_procurement_request( AircraftProcurementRequest(control_point, FlightType.TRANSPORT, gap) ) diff --git a/game/utils.py b/game/utils.py index 119a741a..fbb28d0e 100644 --- a/game/utils.py +++ b/game/utils.py @@ -62,6 +62,8 @@ class Distance: def __mul__(self, other: Union[float, int]) -> Distance: return meters(self.meters * other) + __rmul__ = __mul__ + def __truediv__(self, other: Union[float, int]) -> Distance: return meters(self.meters / other) @@ -147,6 +149,8 @@ class Speed: def __mul__(self, other: Union[float, int]) -> Speed: return kph(self.kph * other) + __rmul__ = __mul__ + def __truediv__(self, other: Union[float, int]) -> Speed: return kph(self.kph / other) diff --git a/game/version.py b/game/version.py index 8ba1748a..11d6cee5 100644 --- a/game/version.py +++ b/game/version.py @@ -12,7 +12,7 @@ def _build_version_string() -> str: ] build_number_path = Path("resources/buildnumber") if build_number_path.exists(): - with build_number_path.open("r") as build_number_file: + with build_number_path.open("r", encoding="utf-8") as build_number_file: components.append(build_number_file.readline()) if not Path("resources/final").exists(): @@ -114,4 +114,8 @@ VERSION = _build_version_string() #: Version 8.1 #: * You can now add "Invisible FARP" static to FOB to add helicopter slots #: -CAMPAIGN_FORMAT_VERSION = (8, 1) +#: Version 9.0 +#: * Campaign files now define the initial squadron layouts. See TODO. +#: * CV and LHA control points now get their names from the group name in the campaign +#: miz. +CAMPAIGN_FORMAT_VERSION = (9, 0) diff --git a/game/weather.py b/game/weather.py index fb0ea68c..0d20b370 100644 --- a/game/weather.py +++ b/game/weather.py @@ -10,7 +10,6 @@ from typing import Optional, TYPE_CHECKING, Any from dcs.cloud_presets import Clouds as PydcsClouds from dcs.weather import CloudPreset, Weather as PydcsWeather, Wind -from game.savecompat import has_save_compat_for from game.settings import Settings from game.utils import Distance, Heading, meters, interpolate, Pressure, inches_hg @@ -36,13 +35,6 @@ class AtmosphericConditions: #: Temperature at sea level in Celcius. temperature_celsius: float - @has_save_compat_for(5) - def __setstate__(self, state: dict[str, Any]) -> None: - if "qnh" not in state: - state["qnh"] = inches_hg(state["qnh_inches_mercury"]) - del state["qnh_inches_mercury"] - self.__dict__.update(state) - @dataclass(frozen=True) class WindConditions: diff --git a/gen/__init__.py b/gen/__init__.py index 6fd6547c..e69de29b 100644 --- a/gen/__init__.py +++ b/gen/__init__.py @@ -1,13 +0,0 @@ -from .aircraft import * -from .armor import * -from .airsupportgen import * -from .conflictgen import * -from .visualgen import * -from .triggergen import * -from .environmentgen import * -from .groundobjectsgen import * -from .briefinggen import * -from .forcedoptionsgen import * -from .kneeboard import * - -from . import naming diff --git a/gen/aircraft.py b/gen/aircraft.py index bccc8097..024447bd 100644 --- a/gen/aircraft.py +++ b/gen/aircraft.py @@ -69,7 +69,6 @@ from game.data.weapons import Pylon, WeaponType as WeaponTypeEnum from game.dcs.aircrafttype import AircraftType from game.factions.faction import Faction from game.settings import Settings -from game.squadrons import Pilot from game.theater.controlpoint import ( Airfield, ControlPoint, @@ -109,6 +108,7 @@ from .naming import namegen if TYPE_CHECKING: from game import Game + from game.squadrons import Pilot, Squadron WARM_START_HELI_ALT = meters(500) WARM_START_ALTITUDE = meters(3000) @@ -644,8 +644,7 @@ class AircraftConflictGenerator: def spawn_unused_aircraft( self, player_country: Country, enemy_country: Country ) -> None: - inventories = self.game.aircraft_inventory.inventories - for control_point, inventory in inventories.items(): + for control_point in self.game.theater.controlpoints: if not isinstance(control_point, Airfield): continue @@ -655,11 +654,9 @@ class AircraftConflictGenerator: else: country = enemy_country - for aircraft, available in inventory.all_aircraft: + for squadron in control_point.squadrons: try: - self._spawn_unused_at( - control_point, country, faction, aircraft, available - ) + self._spawn_unused_at(control_point, country, faction, squadron) except NoParkingSlotError: # If we run out of parking, stop spawning aircraft. return @@ -669,17 +666,16 @@ class AircraftConflictGenerator: control_point: Airfield, country: Country, faction: Faction, - aircraft: AircraftType, - number: int, + squadron: Squadron, ) -> None: - for _ in range(number): + for _ in range(squadron.untasked_aircraft): # Creating a flight even those this isn't a fragged mission lets us # reuse the existing debriefing code. # TODO: Special flight type? flight = Flight( Package(control_point), faction.country, - self.game.air_wing_for(control_point.captured).squadron_for(aircraft), + squadron, 1, FlightType.BARCAP, "Cold", @@ -691,16 +687,13 @@ class AircraftConflictGenerator: group = self._generate_at_airport( name=namegen.next_aircraft_name(country, control_point.id, flight), side=country, - unit_type=aircraft.dcs_unit_type, + unit_type=squadron.aircraft.dcs_unit_type, count=1, start_type="Cold", airport=control_point.airport, ) - if aircraft in faction.liveries_overrides: - livery = random.choice(faction.liveries_overrides[aircraft]) - for unit in group.units: - unit.livery_id = livery + self._setup_livery(flight, group) group.uncontrolled = True self.unit_map.add_aircraft(group, flight) @@ -1837,17 +1830,11 @@ class RaceTrackBuilder(PydcsWaypointBuilder): ) ) - # TODO: Set orbit speeds for all race tracks and remove this special case. - if isinstance(flight_plan, RefuelingFlightPlan): - orbit = OrbitAction( - altitude=waypoint.alt, - pattern=OrbitAction.OrbitPattern.RaceTrack, - speed=int(flight_plan.patrol_speed.kph), - ) - else: - orbit = OrbitAction( - altitude=waypoint.alt, pattern=OrbitAction.OrbitPattern.RaceTrack - ) + orbit = OrbitAction( + altitude=waypoint.alt, + pattern=OrbitAction.OrbitPattern.RaceTrack, + speed=int(flight_plan.patrol_speed.kph), + ) racetrack = ControlledTask(orbit) self.set_waypoint_tot(waypoint, flight_plan.patrol_start_time) diff --git a/gen/airsupport.py b/gen/airsupport.py index 1ce520de..fa189376 100644 --- a/gen/airsupport.py +++ b/gen/airsupport.py @@ -5,7 +5,8 @@ from datetime import timedelta from typing import Optional, TYPE_CHECKING if TYPE_CHECKING: - from gen import RadioFrequency, TacanChannel + from gen.radios import RadioFrequency + from gen.tacan import TacanChannel @dataclass diff --git a/gen/airsupportgen.py b/gen/airsupportgen.py index 2f20a7c3..44456dcc 100644 --- a/gen/airsupportgen.py +++ b/gen/airsupportgen.py @@ -16,8 +16,7 @@ from dcs.task import ( from dcs.unittype import UnitType from game.utils import Heading -from . import AirSupport -from .airsupport import TankerInfo, AwacsInfo +from .airsupport import AirSupport, TankerInfo, AwacsInfo from .callsigns import callsign_for_support_unit from .conflictgen import Conflict from .flights.ai_flight_planner_db import AEWC_CAPABLE diff --git a/gen/flights/ai_flight_planner_db.py b/gen/flights/ai_flight_planner_db.py index 59e49406..4b868bef 100644 --- a/gen/flights/ai_flight_planner_db.py +++ b/gen/flights/ai_flight_planner_db.py @@ -129,7 +129,6 @@ CAP_CAPABLE = [ F_14B, F_14A_135_GR, Su_33, - Su_34, J_11A, Su_30, Su_27, diff --git a/gen/flights/flight.py b/gen/flights/flight.py index 5a7bf855..d7bc14c3 100644 --- a/gen/flights/flight.py +++ b/gen/flights/flight.py @@ -2,20 +2,19 @@ from __future__ import annotations from datetime import timedelta from enum import Enum -from typing import List, Optional, TYPE_CHECKING, Union, Sequence, Any +from typing import List, Optional, TYPE_CHECKING, Union, Sequence from dcs.mapping import Point from dcs.point import MovingPoint, PointAction from dcs.unit import Unit from game.dcs.aircrafttype import AircraftType -from game.savecompat import has_save_compat_for -from game.squadrons import Pilot, Squadron from game.theater.controlpoint import ControlPoint, MissionTarget from game.utils import Distance, meters from gen.flights.loadouts import Loadout if TYPE_CHECKING: + from game.squadrons import Pilot, Squadron from game.transfers import TransferOrder from gen.ato import Package from gen.flights.flightplan import FlightPlan @@ -50,6 +49,8 @@ class FlightType(Enum): strike-like missions will need more specialized control. * ai_flight_planner.py: Use the new mission type in propose_missions so the AI will plan the new mission type. + * FlightType.is_air_to_air and FlightType.is_air_to_ground: If the new mission type + fits either of these categories, update those methods accordingly. """ TARCAP = "TARCAP" @@ -80,6 +81,30 @@ class FlightType(Enum): return entry raise KeyError(f"No FlightType with name {name}") + @property + def is_air_to_air(self) -> bool: + return self in { + FlightType.TARCAP, + FlightType.BARCAP, + FlightType.INTERCEPTION, + FlightType.ESCORT, + FlightType.SWEEP, + } + + @property + def is_air_to_ground(self) -> bool: + return self in { + FlightType.CAS, + FlightType.STRIKE, + FlightType.ANTISHIP, + FlightType.SEAD, + FlightType.DEAD, + FlightType.BAI, + FlightType.OCA_RUNWAY, + FlightType.OCA_AIRCRAFT, + FlightType.SEAD_ESCORT, + } + class FlightWaypointType(Enum): """Enumeration of waypoint types. @@ -141,8 +166,8 @@ class FlightWaypoint: waypoint_type: The waypoint type. x: X coordinate of the waypoint. y: Y coordinate of the waypoint. - alt: Altitude of the waypoint. By default this is AGL, but it can be - changed to MSL by setting alt_type to "RADIO". + alt: Altitude of the waypoint. By default this is MSL, but it can be + changed to AGL by setting alt_type to "RADIO" """ self.waypoint_type = waypoint_type self.x = x @@ -169,12 +194,6 @@ class FlightWaypoint: self.tot: Optional[timedelta] = None self.departure_time: Optional[timedelta] = None - @has_save_compat_for(5) - def __setstate__(self, state: dict[str, Any]) -> None: - if "min_fuel" not in state: - state["min_fuel"] = None - self.__dict__.update(state) - @property def position(self) -> Point: return Point(self.x, self.y) @@ -273,6 +292,7 @@ class Flight: self.package = package self.country = country self.squadron = squadron + self.squadron.claim_inventory(count) if roster is None: self.roster = FlightRoster(self.squadron, initial_size=count) else: @@ -321,6 +341,7 @@ class Flight: return self.flight_plan.waypoints[1:] def resize(self, new_size: int) -> None: + self.squadron.claim_inventory(new_size - self.count) self.roster.resize(new_size) def set_pilot(self, index: int, pilot: Optional[Pilot]) -> None: @@ -330,8 +351,9 @@ class Flight: def missing_pilots(self) -> int: return self.roster.missing_pilots - def clear_roster(self) -> None: + def return_pilots_and_aircraft(self) -> None: self.roster.clear() + self.squadron.claim_inventory(-self.count) def __repr__(self) -> str: if self.custom_name: diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py index 463be24d..032c84e2 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -411,6 +411,9 @@ class PatrollingFlightPlan(FlightPlan): #: Maximum time to remain on station. patrol_duration: timedelta + #: Racetrack speed TAS. + patrol_speed: Speed + #: The engagement range of any Search Then Engage task, or the radius of a #: Search Then Engage in Zone task. Any enemies of the appropriate type for #: this mission within this range of the flight's current position (or the @@ -779,9 +782,6 @@ class RefuelingFlightPlan(PatrollingFlightPlan): divert: Optional[FlightWaypoint] bullseye: FlightWaypoint - #: Racetrack speed. - patrol_speed: Speed - def iter_waypoints(self) -> Iterator[FlightWaypoint]: yield self.takeoff yield from self.nav_to @@ -1115,7 +1115,7 @@ class FlightPlanBuilder: if isinstance(location, FrontLine): raise InvalidObjectiveLocation(flight.flight_type, location) - start_pos, end_pos = self.racetrack_for_objective(location, barcap=True) + start_pos, end_pos = self.cap_racetrack_for_objective(location, barcap=True) preferred_alt = flight.unit_type.preferred_patrol_altitude randomized_alt = preferred_alt + feet(random.randint(-2, 1) * 1000) @@ -1124,6 +1124,11 @@ class FlightPlanBuilder: min(self.doctrine.max_patrol_altitude, randomized_alt), ) + patrol_speed = flight.unit_type.preferred_patrol_speed(patrol_alt) + logging.debug( + f"BARCAP patrol speed for {flight.unit_type.name} at {patrol_alt.feet}ft: {patrol_speed.knots} KTAS" + ) + builder = WaypointBuilder(flight, self.coalition) start, end = builder.race_track(start_pos, end_pos, patrol_alt) @@ -1131,6 +1136,7 @@ class FlightPlanBuilder: package=self.package, flight=flight, patrol_duration=self.doctrine.cap_duration, + patrol_speed=patrol_speed, engagement_distance=self.doctrine.cap_engagement_range, takeoff=builder.takeoff(flight.departure), nav_to=builder.nav_path( @@ -1238,7 +1244,7 @@ class FlightPlanBuilder: bullseye=builder.bullseye(), ) - def racetrack_for_objective( + def cap_racetrack_for_objective( self, location: MissionTarget, barcap: bool ) -> Tuple[Point, Point]: closest_cache = ObjectiveDistanceCache.get_closest_airfields(location) @@ -1270,6 +1276,7 @@ class FlightPlanBuilder: - self.doctrine.cap_engagement_range - nautical_miles(5) ) + max_track_length = self.doctrine.cap_max_track_length else: # Other race tracks (TARCAPs, currently) just try to keep some # distance from the nearest enemy airbase, but since they are by @@ -1283,6 +1290,11 @@ class FlightPlanBuilder: ) distance_to_no_fly = distance_to_airfield - min_distance_from_enemy + # TARCAPs fly short racetracks because they need to react faster. + max_track_length = self.doctrine.cap_min_track_length + 0.3 * ( + self.doctrine.cap_max_track_length - self.doctrine.cap_min_track_length + ) + min_cap_distance = min( self.doctrine.cap_min_distance_from_cp, distance_to_no_fly ) @@ -1294,11 +1306,12 @@ class FlightPlanBuilder: heading.degrees, random.randint(int(min_cap_distance.meters), int(max_cap_distance.meters)), ) - diameter = random.randint( + + track_length = random.randint( int(self.doctrine.cap_min_track_length.meters), - int(self.doctrine.cap_max_track_length.meters), + int(max_track_length.meters), ) - start = end.point_from_heading(heading.opposite.degrees, diameter) + start = end.point_from_heading(heading.opposite.degrees, track_length) return start, end def aewc_orbit(self, location: MissionTarget) -> Point: @@ -1321,33 +1334,6 @@ class FlightPlanBuilder: orbit_heading.degrees, orbit_distance.meters ) - def racetrack_for_frontline( - self, origin: Point, front_line: FrontLine - ) -> Tuple[Point, Point]: - # Find targets waypoints - ingress, heading, distance = Conflict.frontline_vector(front_line, self.theater) - center = ingress.point_from_heading(heading.degrees, distance / 2) - orbit_center = center.point_from_heading( - heading.left.degrees, - random.randint( - int(nautical_miles(6).meters), int(nautical_miles(15).meters) - ), - ) - - combat_width = distance / 2 - if combat_width > 500000: - combat_width = 500000 - if combat_width < 35000: - combat_width = 35000 - - radius = combat_width * 1.25 - start = orbit_center.point_from_heading(heading.degrees, radius) - end = orbit_center.point_from_heading(heading.opposite.degrees, radius) - - if end.distance_to_point(origin) < start.distance_to_point(origin): - start, end = end, start - return start, end - def generate_tarcap(self, flight: Flight) -> TarCapFlightPlan: """Generate a CAP flight plan for the given front line. @@ -1362,16 +1348,14 @@ class FlightPlanBuilder: self.doctrine.min_patrol_altitude, min(self.doctrine.max_patrol_altitude, randomized_alt), ) + patrol_speed = flight.unit_type.preferred_patrol_speed(patrol_alt) + logging.debug( + f"TARCAP patrol speed for {flight.unit_type.name} at {patrol_alt.feet}ft: {patrol_speed.knots} KTAS" + ) # Create points builder = WaypointBuilder(flight, self.coalition) - - if isinstance(location, FrontLine): - orbit0p, orbit1p = self.racetrack_for_frontline( - flight.departure.position, location - ) - else: - orbit0p, orbit1p = self.racetrack_for_objective(location, barcap=False) + orbit0p, orbit1p = self.cap_racetrack_for_objective(location, barcap=False) start, end = builder.race_track(orbit0p, orbit1p, patrol_alt) return TarCapFlightPlan( @@ -1383,6 +1367,7 @@ class FlightPlanBuilder: # requests an escort the CAP flight will remain on station for the # duration of the escorted mission, or until it is winchester/bingo. patrol_duration=self.doctrine.cap_duration, + patrol_speed=patrol_speed, engagement_distance=self.doctrine.cap_engagement_range, takeoff=builder.takeoff(flight.departure), nav_to=builder.nav_path(flight.departure.position, orbit0p, patrol_alt), @@ -1546,16 +1531,33 @@ class FlightPlanBuilder: builder = WaypointBuilder(flight, self.coalition) + # 2021-08-02: patrol_speed will currently have no effect because + # CAS doesn't use OrbitAction. But all PatrollingFlightPlan are expected + # to have patrol_speed + is_helo = flight.unit_type.dcs_unit_type.helicopter + ingress_egress_altitude = ( + self.doctrine.ingress_altitude if not is_helo else meters(50) + ) + patrol_speed = flight.unit_type.preferred_patrol_speed(ingress_egress_altitude) + use_agl_ingress_egress = is_helo + return CasFlightPlan( package=self.package, flight=flight, patrol_duration=self.doctrine.cas_duration, + patrol_speed=patrol_speed, takeoff=builder.takeoff(flight.departure), nav_to=builder.nav_path( - flight.departure.position, ingress, self.doctrine.ingress_altitude + flight.departure.position, + ingress, + ingress_egress_altitude, + use_agl_ingress_egress, ), nav_from=builder.nav_path( - egress, flight.arrival.position, self.doctrine.ingress_altitude + egress, + flight.arrival.position, + ingress_egress_altitude, + use_agl_ingress_egress, ), patrol_start=builder.ingress( FlightWaypointType.INGRESS_CAS, ingress, location @@ -1608,6 +1610,7 @@ class FlightPlanBuilder: else: altitude = feet(21000) + # TODO: Could use flight.unit_type.preferred_patrol_speed(altitude) instead. if tanker_type.patrol_speed is not None: speed = tanker_type.patrol_speed else: diff --git a/gen/flights/waypointbuilder.py b/gen/flights/waypointbuilder.py index 05ca1d93..3c5851fd 100644 --- a/gen/flights/waypointbuilder.py +++ b/gen/flights/waypointbuilder.py @@ -1,4 +1,5 @@ from __future__ import annotations +import logging import random from dataclasses import dataclass @@ -55,7 +56,7 @@ class WaypointBuilder: @property def is_helo(self) -> bool: - return getattr(self.flight.unit_type, "helicopter", False) + return self.flight.unit_type.dcs_unit_type.helicopter def takeoff(self, departure: ControlPoint) -> FlightWaypoint: """Create takeoff waypoint for the given arrival airfield or carrier. @@ -167,6 +168,8 @@ class WaypointBuilder: position.y, meters(500) if self.is_helo else self.doctrine.rendezvous_altitude, ) + if self.is_helo: + waypoint.alt_type = "RADIO" waypoint.pretty_name = "Hold" waypoint.description = "Wait until push time" waypoint.name = "HOLD" @@ -210,7 +213,7 @@ class WaypointBuilder: ingress_type, position.x, position.y, - meters(50) if self.is_helo else self.doctrine.ingress_altitude, + meters(60) if self.is_helo else self.doctrine.ingress_altitude, ) if self.is_helo: waypoint.alt_type = "RADIO" @@ -225,7 +228,7 @@ class WaypointBuilder: FlightWaypointType.EGRESS, position.x, position.y, - meters(50) if self.is_helo else self.doctrine.ingress_altitude, + meters(60) if self.is_helo else self.doctrine.ingress_altitude, ) if self.is_helo: waypoint.alt_type = "RADIO" @@ -309,7 +312,7 @@ class WaypointBuilder: FlightWaypointType.CAS, position.x, position.y, - meters(50) if self.is_helo else meters(1000), + meters(60) if self.is_helo else meters(1000), ) waypoint.alt_type = "RADIO" waypoint.description = "Provide CAS" @@ -445,7 +448,7 @@ class WaypointBuilder: FlightWaypointType.TARGET_GROUP_LOC, target.position.x, target.position.y, - meters(50) if self.is_helo else self.doctrine.ingress_altitude, + meters(60) if self.is_helo else self.doctrine.ingress_altitude, ) if self.is_helo: waypoint.alt_type = "RADIO" diff --git a/gen/forcedoptionsgen.py b/gen/forcedoptionsgen.py index e4025d48..0e5be7bc 100644 --- a/gen/forcedoptionsgen.py +++ b/gen/forcedoptionsgen.py @@ -43,8 +43,14 @@ class ForcedOptionsGenerator: if blue.unrestricted_satnav or red.unrestricted_satnav: self.mission.forced_options.unrestricted_satnav = True + def _set_battle_damage_assessment(self) -> None: + self.mission.forced_options.battle_damage_assessment = ( + self.game.settings.battle_damage_assessment + ) + def generate(self) -> None: self._set_options_view() self._set_external_views() self._set_labels() self._set_unrestricted_satnav() + self._set_battle_damage_assessment() diff --git a/gen/kneeboard.py b/gen/kneeboard.py index a2be2ef6..aa9cfb94 100644 --- a/gen/kneeboard.py +++ b/gen/kneeboard.py @@ -65,7 +65,8 @@ class KneeboardPageWriter: else: self.foreground_fill = (15, 15, 15) self.background_fill = (255, 252, 252) - self.image = Image.new("RGB", (768, 1024), self.background_fill) + self.image_size = (768, 1024) + self.image = Image.new("RGB", self.image_size, self.background_fill) # These font sizes create a relatively full page for current sorties. If # we start generating more complicated flight plans, or start including # more information in the comm ladder (the latter of which we should @@ -84,6 +85,7 @@ class KneeboardPageWriter: "resources/fonts/Inconsolata.otf", 20, layout_engine=ImageFont.LAYOUT_BASIC ) self.draw = ImageDraw.Draw(self.image) + self.page_margin = page_margin self.x = page_margin self.y = page_margin self.line_spacing = line_spacing @@ -97,12 +99,21 @@ class KneeboardPageWriter: text: str, font: Optional[ImageFont.FreeTypeFont] = None, fill: Optional[Tuple[int, int, int]] = None, + wrap: bool = False, ) -> None: if font is None: font = self.content_font if fill is None: fill = self.foreground_fill + if wrap: + text = "\n".join( + self.wrap_line_with_font( + line, self.image_size[0] - self.page_margin - self.x, font + ) + for line in text.splitlines() + ) + self.draw.text(self.position, text, font=font, fill=fill) width, height = self.draw.textsize(text, font=font) self.y += height + self.line_spacing @@ -146,6 +157,24 @@ class KneeboardPageWriter: output = combo return "".join(segments + [output]).strip() + @staticmethod + def wrap_line_with_font( + inputstr: str, max_width: int, font: ImageFont.FreeTypeFont + ) -> str: + if font.getsize(inputstr)[0] <= max_width: + return inputstr + tokens = inputstr.split(" ") + output = "" + segments = [] + for token in tokens: + combo = output + " " + token + if font.getsize(combo)[0] > max_width: + segments.append(output + "\n") + output = token + else: + output = combo + return "".join(segments + [output]).strip() + class KneeboardPage: """Base class for all kneeboard pages.""" @@ -631,7 +660,7 @@ class NotesPage(KneeboardPage): def write(self, path: Path) -> None: writer = KneeboardPageWriter(dark_theme=self.dark_kneeboard) writer.title(f"Notes") - writer.text(self.notes) + writer.text(self.notes, wrap=True) writer.write(path) diff --git a/gen/naming.py b/gen/naming.py index e43d629c..9f820a86 100644 --- a/gen/naming.py +++ b/gen/naming.py @@ -1,12 +1,16 @@ +from __future__ import annotations + import random import time -from typing import List, Any +from typing import List, Any, TYPE_CHECKING from dcs.country import Country from game.dcs.aircrafttype import AircraftType from game.dcs.unittype import UnitType -from gen.flights.flight import Flight + +if TYPE_CHECKING: + from gen.flights.flight import Flight ALPHA_MILITARY = [ "Alpha", diff --git a/gen/radios.py b/gen/radios.py index ced4ac9c..6cffb372 100644 --- a/gen/radios.py +++ b/gen/radios.py @@ -2,7 +2,7 @@ import itertools import logging from dataclasses import dataclass -from typing import Dict, Iterator, List, Set +from typing import Dict, FrozenSet, Iterator, List, Reversible, Set, Tuple @dataclass(frozen=True) @@ -45,14 +45,8 @@ def kHz(num: int) -> RadioFrequency: @dataclass(frozen=True) -class Radio: - """A radio. - - Defines the minimum (inclusive) and maximum (exclusive) range of the radio. - """ - - #: The name of the radio. - name: str +class RadioRange: + """Defines the minimum (inclusive) and maximum (exclusive) range of the radio.""" #: The minimum (inclusive) frequency tunable by this radio. minimum: RadioFrequency @@ -63,19 +57,51 @@ class Radio: #: The spacing between adjacent frequencies. step: RadioFrequency - def __str__(self) -> str: - return self.name + #: Specific frequencies to exclude. (e.g. Guard channels) + excludes: FrozenSet[RadioFrequency] = frozenset() def range(self) -> Iterator[RadioFrequency]: """Returns an iterator over the usable frequencies of this radio.""" return ( RadioFrequency(x) for x in range(self.minimum.hertz, self.maximum.hertz, self.step.hertz) + if RadioFrequency(x) not in self.excludes ) @property def last_channel(self) -> RadioFrequency: - return RadioFrequency(self.maximum.hertz - self.step.hertz) + return next( + RadioFrequency(x) + for x in reversed( + range(self.minimum.hertz, self.maximum.hertz, self.step.hertz) + ) + if RadioFrequency(x) not in self.excludes + ) + + +@dataclass(frozen=True) +class Radio: + """A radio. + + Defines ranges of usable frequencies of the radio. + """ + + #: The name of the radio. + name: str + + #: List of usable frequency range of this radio. + ranges: Tuple[RadioRange, ...] + + def __str__(self) -> str: + return self.name + + def range(self) -> Iterator[RadioFrequency]: + """Returns an iterator over the usable frequencies of this radio.""" + return itertools.chain.from_iterable(rng.range() for rng in self.ranges) + + @property + def last_channel(self) -> RadioFrequency: + return self.ranges[-1].last_channel class ChannelInUseError(RuntimeError): @@ -88,53 +114,58 @@ class ChannelInUseError(RuntimeError): # TODO: Figure out appropriate steps for each radio. These are just guesses. #: List of all known radios used by aircraft in the game. RADIOS: List[Radio] = [ - Radio("AN/ARC-164", MHz(225), MHz(400), step=MHz(1)), - Radio("AN/ARC-186(V) AM", MHz(116), MHz(152), step=MHz(1)), - Radio("AN/ARC-186(V) FM", MHz(30), MHz(76), step=MHz(1)), - # The AN/ARC-210 can also use [30, 88) and [108, 118), but the current - # implementation can't implement the gap and the radio can't transmit on the - # latter. There's still plenty of channels between 118 MHz and 400 MHz, so - # not worth worrying about. - Radio("AN/ARC-210", MHz(118), MHz(400), step=MHz(1)), - Radio("AN/ARC-222", MHz(116), MHz(174), step=MHz(1)), - Radio("SCR-522", MHz(100), MHz(156), step=MHz(1)), - Radio("A.R.I. 1063", MHz(100), MHz(156), step=MHz(1)), - Radio("BC-1206", kHz(200), kHz(400), step=kHz(10)), + Radio("AN/ARC-164", (RadioRange(MHz(225), MHz(400), step=MHz(1)),)), + Radio("AN/ARC-186(V) AM", (RadioRange(MHz(116), MHz(152), step=MHz(1)),)), + Radio("AN/ARC-186(V) FM", (RadioRange(MHz(30), MHz(76), step=MHz(1)),)), + Radio( + "AN/ARC-210", + ( + RadioRange(MHz(225), MHz(400), MHz(1), frozenset((MHz(243),))), + RadioRange(MHz(136), MHz(155), MHz(1)), + RadioRange(MHz(156), MHz(174), MHz(1)), + RadioRange(MHz(118), MHz(136), MHz(1)), + RadioRange(MHz(30), MHz(88), MHz(1)), + ), + ), + Radio("AN/ARC-222", (RadioRange(MHz(116), MHz(174), step=MHz(1)),)), + Radio("SCR-522", (RadioRange(MHz(100), MHz(156), step=MHz(1)),)), + Radio("A.R.I. 1063", (RadioRange(MHz(100), MHz(156), step=MHz(1)),)), + Radio("BC-1206", (RadioRange(kHz(200), kHz(400), step=kHz(10)),)), # Note: The M2000C V/UHF can operate in both ranges, but has a gap between # 150 MHz and 225 MHz. We can't allocate in that gap, and the current # system doesn't model gaps, so just pretend it ends at 150 MHz for now. We # can model gaps later if needed. - Radio("TRT ERA 7000 V/UHF", MHz(118), MHz(150), step=MHz(1)), - Radio("TRT ERA 7200 UHF", MHz(225), MHz(400), step=MHz(1)), + Radio("TRT ERA 7000 V/UHF", (RadioRange(MHz(118), MHz(150), step=MHz(1)),)), + Radio("TRT ERA 7200 UHF", (RadioRange(MHz(225), MHz(400), step=MHz(1)),)), # Tomcat radios # # https://www.heatblur.se/F-14Manual/general.html#an-arc-159-uhf-1-radio - Radio("AN/ARC-159", MHz(225), MHz(400), step=MHz(1)), + Radio("AN/ARC-159", (RadioRange(MHz(225), MHz(400), step=MHz(1)),)), # AN/ARC-182 can also operate from 30 MHz to 88 MHz, as well as from 225 MHz # to 400 MHz range, but we can't model gaps with the current implementation. # https://www.heatblur.se/F-14Manual/general.html#an-arc-182-v-uhf-2-radio - Radio("AN/ARC-182", MHz(108), MHz(174), step=MHz(1)), + Radio("AN/ARC-182", (RadioRange(MHz(108), MHz(174), step=MHz(1)),)), # Also capable of [103, 156) at 25 kHz intervals, but we can't do gaps. - Radio("FR 22", MHz(225), MHz(400), step=kHz(50)), + Radio("FR 22", (RadioRange(MHz(225), MHz(400), step=kHz(50)),)), # P-51 / P-47 Radio # 4 preset channels (A/B/C/D) - Radio("SCR522", MHz(100), MHz(156), step=kHz(25)), - Radio("R&S M3AR VHF", MHz(120), MHz(174), step=MHz(1)), - Radio("R&S M3AR UHF", MHz(225), MHz(400), step=MHz(1)), + Radio("SCR522", (RadioRange(MHz(100), MHz(156), step=kHz(25)),)), + Radio("R&S M3AR VHF", (RadioRange(MHz(120), MHz(174), step=MHz(1)),)), + Radio("R&S M3AR UHF", (RadioRange(MHz(225), MHz(400), step=MHz(1)),)), # MiG-15bis - Radio("RSI-6K HF", MHz(3, 750), MHz(5), step=kHz(25)), + Radio("RSI-6K HF", (RadioRange(MHz(3, 750), MHz(5), step=kHz(25)),)), # MiG-19P - Radio("RSIU-4V", MHz(100), MHz(150), step=MHz(1)), + Radio("RSIU-4V", (RadioRange(MHz(100), MHz(150), step=MHz(1)),)), # MiG-21bis - Radio("RSIU-5V", MHz(118), MHz(140), step=MHz(1)), + Radio("RSIU-5V", (RadioRange(MHz(118), MHz(140), step=MHz(1)),)), # Ka-50 # Note: Also capable of 100MHz-150MHz, but we can't model gaps. - Radio("R-800L1", MHz(220), MHz(400), step=kHz(25)), - Radio("R-828", MHz(20), MHz(60), step=kHz(25)), + Radio("R-800L1", (RadioRange(MHz(220), MHz(400), step=kHz(25)),)), + Radio("R-828", (RadioRange(MHz(20), MHz(60), step=kHz(25)),)), # UH-1H - Radio("AN/ARC-51BX", MHz(225), MHz(400), step=kHz(50)), - Radio("AN/ARC-131", MHz(30), MHz(76), step=kHz(50)), - Radio("AN/ARC-134", MHz(116), MHz(150), step=kHz(25)), - Radio("R&S Series 6000", MHz(100), MHz(156), step=kHz(25)), + Radio("AN/ARC-51BX", (RadioRange(MHz(225), MHz(400), step=kHz(50)),)), + Radio("AN/ARC-131", (RadioRange(MHz(30), MHz(76), step=kHz(50)),)), + Radio("AN/ARC-134", (RadioRange(MHz(116), MHz(150), step=kHz(25)),)), + Radio("R&S Series 6000", (RadioRange(MHz(100), MHz(156), step=kHz(25)),)), ] @@ -175,7 +206,7 @@ class RadioRegistry: # Not a real radio, but useful for allocating a channel usable for # inter-flight communications. - BLUFOR_UHF = Radio("BLUFOR UHF", MHz(225), MHz(400), step=MHz(1)) + BLUFOR_UHF = Radio("BLUFOR UHF", (RadioRange(MHz(225), MHz(400), step=MHz(1)),)) def __init__(self) -> None: self.allocated_channels: Set[RadioFrequency] = set() diff --git a/gen/sam/aaa_flak.py b/gen/sam/aaa_flak.py index 0e27a8d2..72f6fb2e 100644 --- a/gen/sam/aaa_flak.py +++ b/gen/sam/aaa_flak.py @@ -82,8 +82,10 @@ class FlakGenerator(AirDefenseGroupGenerator): ) # Some Opel Blitz trucks + index = 0 for i in range(int(max(1, 2))): for j in range(int(max(1, 2))): + index += 1 self.add_unit( Unarmed.Blitz_36_6700A, "BLITZ#" + str(index), diff --git a/gen/sam/ewrs.py b/gen/sam/ewrs.py index 2ffc93da..854337b6 100644 --- a/gen/sam/ewrs.py +++ b/gen/sam/ewrs.py @@ -16,7 +16,11 @@ class EwrGenerator(VehicleGroupGenerator[EwrGroundObject]): def generate(self) -> None: self.add_unit( - self.unit_type, "EWR", self.position.x, self.position.y, self.heading + self.unit_type, + "EWR", + self.position.x, + self.position.y, + self.heading_to_conflict(), ) diff --git a/qt_ui/main.py b/qt_ui/main.py index 70d4dd5b..c4d6e4e0 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, @@ -64,7 +64,8 @@ def run_ui(game: Optional[Game]) -> None: # init the theme and load the stylesheet based on the theme index liberation_theme.init() with open( - "./resources/stylesheets/" + liberation_theme.get_theme_css_file() + "./resources/stylesheets/" + liberation_theme.get_theme_css_file(), + encoding="utf-8", ) as stylesheet: logging.info("Loading stylesheet: %s", liberation_theme.get_theme_css_file()) app.setStyleSheet(stylesheet.read()) @@ -231,11 +232,13 @@ def create_game( # for loadouts) without saving the generated campaign and reloading it the normal # way. inject_custom_payloads(Path(persistency.base_path())) - campaign = Campaign.from_json(campaign_path) + campaign = Campaign.from_file(campaign_path) + theater = campaign.load_theater() generator = GameGenerator( FACTIONS[blue], FACTIONS[red], - campaign.load_theater(), + theater, + campaign.load_air_wing_config(theater), Settings( supercarrier=supercarrier, automate_runway_repair=auto_procurement, diff --git a/qt_ui/models.py b/qt_ui/models.py index ee842de5..730e588c 100644 --- a/qt_ui/models.py +++ b/qt_ui/models.py @@ -13,7 +13,7 @@ from PySide2.QtCore import ( from PySide2.QtGui import QIcon from game.game import Game -from game.squadrons import Squadron, Pilot +from game.squadrons.squadron import Pilot, Squadron from game.theater.missiontarget import MissionTarget from game.transfers import TransferOrder, PendingTransfers from gen.ato import AirTaskingOrder, Package @@ -165,8 +165,7 @@ class PackageModel(QAbstractListModel): self.beginRemoveRows(QModelIndex(), index, index) if flight.cargo is not None: flight.cargo.transport = None - self.game_model.game.aircraft_inventory.return_from_flight(flight) - flight.clear_roster() + flight.return_pilots_and_aircraft() self.package.remove_flight(flight) self.endRemoveRows() self.update_tot() @@ -258,8 +257,7 @@ class AtoModel(QAbstractListModel): self.beginRemoveRows(QModelIndex(), index, index) self.ato.remove_package(package) for flight in package.flights: - self.game.aircraft_inventory.return_from_flight(flight) - flight.clear_roster() + flight.return_pilots_and_aircraft() if flight.cargo is not None: flight.cargo.transport = None self.endRemoveRows() diff --git a/qt_ui/widgets/combos/QOriginAirfieldSelector.py b/qt_ui/widgets/combos/QOriginAirfieldSelector.py deleted file mode 100644 index 9453a45c..00000000 --- a/qt_ui/widgets/combos/QOriginAirfieldSelector.py +++ /dev/null @@ -1,69 +0,0 @@ -"""Combo box for selecting a departure airfield.""" -from typing import Iterable, Optional - -from PySide2.QtCore import Signal -from PySide2.QtWidgets import QComboBox -from dcs.unittype import FlyingType - -from game.dcs.aircrafttype import AircraftType -from game.inventory import GlobalAircraftInventory -from game.theater.controlpoint import ControlPoint - - -class QOriginAirfieldSelector(QComboBox): - """A combo box for selecting a flight's departure airfield. - - The combo box will automatically be populated with all departure airfields - that have unassigned inventory of the given aircraft type. - """ - - availability_changed = Signal(int) - - def __init__( - self, - global_inventory: GlobalAircraftInventory, - origins: Iterable[ControlPoint], - aircraft: Optional[AircraftType], - ) -> None: - super().__init__() - self.global_inventory = global_inventory - self.origins = list(origins) - self.aircraft = aircraft - self.rebuild_selector() - self.currentIndexChanged.connect(self.index_changed) - self.setSizeAdjustPolicy(self.AdjustToContents) - - def change_aircraft(self, aircraft: Optional[FlyingType]) -> None: - if self.aircraft == aircraft: - return - self.aircraft = aircraft - self.rebuild_selector() - - def rebuild_selector(self) -> None: - self.clear() - if self.aircraft is None: - return - for origin in self.origins: - if not origin.can_operate(self.aircraft): - continue - - inventory = self.global_inventory.for_control_point(origin) - available = inventory.available(self.aircraft) - if available: - self.addItem(f"{origin.name} ({available} available)", origin) - self.model().sort(0) - - @property - def available(self) -> int: - origin = self.currentData() - if origin is None: - return 0 - inventory = self.global_inventory.for_control_point(origin) - return inventory.available(self.aircraft) - - def index_changed(self, index: int) -> None: - origin = self.itemData(index) - if origin is None: - return - inventory = self.global_inventory.for_control_point(origin) - self.availability_changed.emit(inventory.available(self.aircraft)) diff --git a/qt_ui/widgets/combos/QPredefinedWaypointSelectionComboBox.py b/qt_ui/widgets/combos/QPredefinedWaypointSelectionComboBox.py index 32b01bd6..af500dab 100644 --- a/qt_ui/widgets/combos/QPredefinedWaypointSelectionComboBox.py +++ b/qt_ui/widgets/combos/QPredefinedWaypointSelectionComboBox.py @@ -1,10 +1,10 @@ from PySide2.QtGui import QStandardItem, QStandardItemModel from game import Game -from game.theater import ControlPointType +from game.theater import ControlPointType, BuildingGroundObject from game.utils import Distance -from gen import BuildingGroundObject, Conflict, FlightWaypointType -from gen.flights.flight import FlightWaypoint +from gen.conflictgen import Conflict +from gen.flights.flight import FlightWaypoint, FlightWaypointType from qt_ui.widgets.combos.QFilteredComboBox import QFilteredComboBox diff --git a/qt_ui/windows/AirWingConfigurationDialog.py b/qt_ui/windows/AirWingConfigurationDialog.py index 85539d06..745cac5c 100644 --- a/qt_ui/windows/AirWingConfigurationDialog.py +++ b/qt_ui/windows/AirWingConfigurationDialog.py @@ -1,4 +1,4 @@ -from typing import Optional, Callable +from typing import Optional, Callable, Iterable from PySide2.QtCore import ( QItemSelectionModel, @@ -24,11 +24,13 @@ from PySide2.QtWidgets import ( QHBoxLayout, QStackedLayout, QTabWidget, + QComboBox, ) from game import Game from game.dcs.aircrafttype import AircraftType -from game.squadrons import Squadron, AirWing, Pilot +from game.squadrons import AirWing, Pilot, Squadron +from game.theater import ControlPoint, ConflictTheater from gen.flights.flight import FlightType from qt_ui.models import AirWingModel, SquadronModel from qt_ui.uiconstants import AIRCRAFT_ICONS @@ -96,8 +98,33 @@ class AllowedMissionTypeControls(QVBoxLayout): self.allowed_mission_types.remove(task) +class SquadronBaseSelector(QComboBox): + """A combo box for selecting a squadrons home air base. + + The combo box will automatically be populated with all air bases compatible with the + squadron. + """ + + def __init__( + self, + bases: Iterable[ControlPoint], + squadron: Squadron, + ) -> None: + super().__init__() + self.bases = list(bases) + self.squadron = squadron + self.setSizeAdjustPolicy(self.AdjustToContents) + + for base in self.bases: + if not base.can_operate(self.squadron.aircraft): + continue + self.addItem(base.name, base) + self.model().sort(0) + self.setCurrentText(self.squadron.location.name) + + class SquadronConfigurationBox(QGroupBox): - def __init__(self, squadron: Squadron) -> None: + def __init__(self, squadron: Squadron, theater: ConflictTheater) -> None: super().__init__() self.setCheckable(True) self.squadron = squadron @@ -119,6 +146,13 @@ class SquadronConfigurationBox(QGroupBox): self.nickname_edit.textChanged.connect(self.on_nickname_changed) left_column.addWidget(self.nickname_edit) + left_column.addWidget(QLabel("Base:")) + self.base_selector = SquadronBaseSelector( + theater.control_points_for(squadron.player), squadron + ) + self.base_selector.currentIndexChanged.connect(self.on_base_changed) + left_column.addWidget(self.base_selector) + if squadron.player: player_label = QLabel( "Players (one per line, leave empty for an AI-only squadron):" @@ -149,6 +183,12 @@ class SquadronConfigurationBox(QGroupBox): def on_nickname_changed(self, text: str) -> None: self.squadron.nickname = text + def on_base_changed(self, index: int) -> None: + base = self.base_selector.itemData(index) + if base is None: + raise RuntimeError("Base cannot be none") + self.squadron.assign_to_base(base) + def reset_title(self) -> None: self.setTitle(f"{self.squadron.name} - {self.squadron.aircraft}") @@ -158,16 +198,18 @@ class SquadronConfigurationBox(QGroupBox): self.squadron.pilot_pool = [ Pilot(n, player=True) for n in player_names ] + self.squadron.pilot_pool - self.squadron.mission_types = tuple(self.allowed_missions.allowed_mission_types) + self.squadron.set_allowed_mission_types( + self.allowed_missions.allowed_mission_types + ) return self.squadron class SquadronConfigurationLayout(QVBoxLayout): - def __init__(self, squadrons: list[Squadron]) -> None: + def __init__(self, squadrons: list[Squadron], theater: ConflictTheater) -> None: super().__init__() self.squadron_configs = [] for squadron in squadrons: - squadron_config = SquadronConfigurationBox(squadron) + squadron_config = SquadronConfigurationBox(squadron, theater) self.squadron_configs.append(squadron_config) self.addWidget(squadron_config) @@ -180,12 +222,12 @@ class SquadronConfigurationLayout(QVBoxLayout): class AircraftSquadronsPage(QWidget): - def __init__(self, squadrons: list[Squadron]) -> None: + def __init__(self, squadrons: list[Squadron], theater: ConflictTheater) -> None: super().__init__() layout = QVBoxLayout() self.setLayout(layout) - self.squadrons_config = SquadronConfigurationLayout(squadrons) + self.squadrons_config = SquadronConfigurationLayout(squadrons, theater) scrolling_widget = QWidget() scrolling_widget.setLayout(self.squadrons_config) @@ -203,12 +245,12 @@ class AircraftSquadronsPage(QWidget): class AircraftSquadronsPanel(QStackedLayout): - def __init__(self, air_wing: AirWing) -> None: + def __init__(self, air_wing: AirWing, theater: ConflictTheater) -> None: super().__init__() self.air_wing = air_wing self.squadrons_pages: dict[AircraftType, AircraftSquadronsPage] = {} for aircraft, squadrons in self.air_wing.squadrons.items(): - page = AircraftSquadronsPage(squadrons) + page = AircraftSquadronsPage(squadrons, theater) self.addWidget(page) self.squadrons_pages[aircraft] = page @@ -260,7 +302,7 @@ class AircraftTypeList(QListView): class AirWingConfigurationTab(QWidget): - def __init__(self, air_wing: AirWing) -> None: + def __init__(self, air_wing: AirWing, theater: ConflictTheater) -> None: super().__init__() layout = QHBoxLayout() @@ -270,7 +312,7 @@ class AirWingConfigurationTab(QWidget): type_list.page_index_changed.connect(self.on_aircraft_changed) layout.addWidget(type_list) - self.squadrons_panel = AircraftSquadronsPanel(air_wing) + self.squadrons_panel = AircraftSquadronsPanel(air_wing, theater) layout.addLayout(self.squadrons_panel) def apply(self) -> None: @@ -315,7 +357,7 @@ class AirWingConfigurationDialog(QDialog): self.tabs = [] for coalition in game.coalitions: - coalition_tab = AirWingConfigurationTab(coalition.air_wing) + coalition_tab = AirWingConfigurationTab(coalition.air_wing, game.theater) name = "Blue" if coalition.player else "Red" tab_widget.addTab(coalition_tab, name) self.tabs.append(coalition_tab) diff --git a/qt_ui/windows/AirWingDialog.py b/qt_ui/windows/AirWingDialog.py index df0cf81c..a8f8ca3f 100644 --- a/qt_ui/windows/AirWingDialog.py +++ b/qt_ui/windows/AirWingDialog.py @@ -16,7 +16,6 @@ from PySide2.QtWidgets import ( QWidget, ) -from game.inventory import ControlPointAircraftInventory from game.squadrons import Squadron from gen.flights.flight import Flight from qt_ui.delegates import TwoColumnRowDelegate @@ -34,13 +33,17 @@ class SquadronDelegate(TwoColumnRowDelegate): return index.data(AirWingModel.SquadronRole) def text_for(self, index: QModelIndex, row: int, column: int) -> str: + squadron = self.squadron(index) if (row, column) == (0, 0): - return self.squadron(index).name + if squadron.nickname: + nickname = f' "{squadron.nickname}"' + else: + nickname = "" + return f"{squadron.name}{nickname}" elif (row, column) == (0, 1): - squadron = self.air_wing_model.data(index, AirWingModel.SquadronRole) return squadron.aircraft.name elif (row, column) == (1, 0): - return self.squadron(index).nickname or "" + return squadron.location.name elif (row, column) == (1, 1): squadron = self.squadron(index) active = len(squadron.active_pilots) @@ -123,19 +126,13 @@ class AircraftInventoryData: ) @classmethod - def each_from_inventory( - cls, inventory: ControlPointAircraftInventory + def each_untasked_from_squadron( + cls, squadron: Squadron ) -> Iterator[AircraftInventoryData]: - for unit_type, num_units in inventory.all_aircraft: - for _ in range(0, num_units): - yield AircraftInventoryData( - inventory.control_point.name, - unit_type.name, - "Idle", - "N/A", - "N/A", - "N/A", - ) + for _ in range(0, squadron.untasked_aircraft): + yield AircraftInventoryData( + squadron.name, squadron.aircraft.name, "Idle", "N/A", "N/A", "N/A" + ) class AirInventoryView(QWidget): @@ -184,9 +181,8 @@ class AirInventoryView(QWidget): def iter_unallocated_aircraft(self) -> Iterator[AircraftInventoryData]: game = self.game_model.game - for control_point, inventory in game.aircraft_inventory.inventories.items(): - if control_point.captured: - yield from AircraftInventoryData.each_from_inventory(inventory) + for squadron in game.blue.air_wing.iter_squadrons(): + yield from AircraftInventoryData.each_untasked_from_squadron(squadron) def get_data(self, only_unallocated: bool) -> Iterator[AircraftInventoryData]: yield from self.iter_unallocated_aircraft() diff --git a/qt_ui/windows/QWaitingForMissionResultWindow.py b/qt_ui/windows/QWaitingForMissionResultWindow.py index f4ce44fb..0361fcaa 100644 --- a/qt_ui/windows/QWaitingForMissionResultWindow.py +++ b/qt_ui/windows/QWaitingForMissionResultWindow.py @@ -228,7 +228,7 @@ class QWaitingForMissionResultWindow(QDialog): ) print(file) try: - with open(file[0], "r") as json_file: + with open(file[0], "r", encoding="utf-8") as json_file: json_data = json.load(json_file) json_data["mission_ended"] = True debriefing = Debriefing(json_data, self.game, self.unit_map) diff --git a/qt_ui/windows/SquadronDialog.py b/qt_ui/windows/SquadronDialog.py index a932caee..c17e5312 100644 --- a/qt_ui/windows/SquadronDialog.py +++ b/qt_ui/windows/SquadronDialog.py @@ -14,7 +14,6 @@ from PySide2.QtWidgets import ( QVBoxLayout, QPushButton, QHBoxLayout, - QGridLayout, QLabel, QCheckBox, ) diff --git a/qt_ui/windows/basemenu/QBaseMenu2.py b/qt_ui/windows/basemenu/QBaseMenu2.py index d10e5bc7..7c333e1b 100644 --- a/qt_ui/windows/basemenu/QBaseMenu2.py +++ b/qt_ui/windows/basemenu/QBaseMenu2.py @@ -24,7 +24,7 @@ from qt_ui.uiconstants import EVENT_ICONS from qt_ui.windows.GameUpdateSignal import GameUpdateSignal from qt_ui.windows.basemenu.NewUnitTransferDialog import NewUnitTransferDialog from qt_ui.windows.basemenu.QBaseMenuTabs import QBaseMenuTabs -from qt_ui.windows.basemenu.QRecruitBehaviour import QRecruitBehaviour +from qt_ui.windows.basemenu.UnitTransactionFrame import UnitTransactionFrame class QBaseMenu2(QDialog): @@ -108,7 +108,7 @@ class QBaseMenu2(QDialog): capture_button.clicked.connect(self.cheat_capture) self.budget_display = QLabel( - QRecruitBehaviour.BUDGET_FORMAT.format(self.game_model.game.blue.budget) + UnitTransactionFrame.BUDGET_FORMAT.format(self.game_model.game.blue.budget) ) self.budget_display.setAlignment(Qt.AlignRight | Qt.AlignBottom) self.budget_display.setProperty("style", "budget-label") @@ -190,7 +190,7 @@ class QBaseMenu2(QDialog): self.repair_button.setDisabled(True) def update_intel_summary(self) -> None: - aircraft = self.cp.base.total_aircraft + aircraft = self.cp.allocated_aircraft(self.game_model.game).total_present parking = self.cp.total_aircraft_parking ground_unit_limit = self.cp.frontline_unit_count_limit deployable_unit_info = "" @@ -258,5 +258,5 @@ class QBaseMenu2(QDialog): def update_budget(self, game: Game) -> None: self.budget_display.setText( - QRecruitBehaviour.BUDGET_FORMAT.format(game.blue.budget) + UnitTransactionFrame.BUDGET_FORMAT.format(game.blue.budget) ) diff --git a/qt_ui/windows/basemenu/QRecruitBehaviour.py b/qt_ui/windows/basemenu/UnitTransactionFrame.py similarity index 53% rename from qt_ui/windows/basemenu/QRecruitBehaviour.py rename to qt_ui/windows/basemenu/UnitTransactionFrame.py index 77b0258b..d9cbe57f 100644 --- a/qt_ui/windows/basemenu/QRecruitBehaviour.py +++ b/qt_ui/windows/basemenu/UnitTransactionFrame.py @@ -1,6 +1,9 @@ from __future__ import annotations import logging +from enum import Enum +from typing import TypeVar, Generic + from PySide2.QtCore import Qt from PySide2.QtWidgets import ( QGroupBox, @@ -11,15 +14,15 @@ from PySide2.QtWidgets import ( QSpacerItem, QGridLayout, QApplication, + QFrame, + QMessageBox, ) from game.dcs.unittype import UnitType -from game.theater import ControlPoint -from game.unitdelivery import PendingUnitDeliveries from qt_ui.models import GameModel from qt_ui.windows.GameUpdateSignal import GameUpdateSignal from qt_ui.windows.QUnitInfoWindow import QUnitInfoWindow -from enum import Enum +from game.purchaseadapter import PurchaseAdapter, TransactionError class RecruitType(Enum): @@ -27,21 +30,28 @@ class RecruitType(Enum): SELL = 1 -class PurchaseGroup(QGroupBox): - def __init__(self, unit_type: UnitType, recruiter: QRecruitBehaviour) -> None: +TransactionItemType = TypeVar("TransactionItemType") + + +class PurchaseGroup(QGroupBox, Generic[TransactionItemType]): + def __init__( + self, + item: TransactionItemType, + recruiter: UnitTransactionFrame[TransactionItemType], + ) -> None: super().__init__() - self.unit_type = unit_type + self.item = item self.recruiter = recruiter self.setProperty("style", "buy-box") - self.setMaximumHeight(36) + self.setMaximumHeight(72) self.setMinimumHeight(36) layout = QHBoxLayout() self.setLayout(layout) self.sell_button = QPushButton("-") self.sell_button.setProperty("style", "btn-sell") - self.sell_button.setDisabled(not recruiter.enable_sale(unit_type)) + self.sell_button.setDisabled(not recruiter.enable_sale(item)) self.sell_button.setMinimumSize(16, 16) self.sell_button.setMaximumSize(16, 16) self.sell_button.setSizePolicy( @@ -49,7 +59,7 @@ class PurchaseGroup(QGroupBox): ) self.sell_button.clicked.connect( - lambda: self.recruiter.recruit_handler(RecruitType.SELL, self.unit_type) + lambda: self.recruiter.recruit_handler(RecruitType.SELL, self.item) ) self.amount_bought = QLabel() @@ -59,12 +69,12 @@ class PurchaseGroup(QGroupBox): self.buy_button = QPushButton("+") self.buy_button.setProperty("style", "btn-buy") - self.buy_button.setDisabled(not recruiter.enable_purchase(unit_type)) + self.buy_button.setDisabled(not recruiter.enable_purchase(item)) self.buy_button.setMinimumSize(16, 16) self.buy_button.setMaximumSize(16, 16) self.buy_button.clicked.connect( - lambda: self.recruiter.recruit_handler(RecruitType.BUY, self.unit_type) + lambda: self.recruiter.recruit_handler(RecruitType.BUY, self.item) ) self.buy_button.setSizePolicy(QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)) @@ -76,30 +86,53 @@ class PurchaseGroup(QGroupBox): @property def pending_units(self) -> int: - return self.recruiter.pending_deliveries.units.get(self.unit_type, 0) + return self.recruiter.pending_delivery_quantity(self.item) def update_state(self) -> None: - self.buy_button.setEnabled(self.recruiter.enable_purchase(self.unit_type)) - self.sell_button.setEnabled(self.recruiter.enable_sale(self.unit_type)) + self.buy_button.setEnabled(self.recruiter.enable_purchase(self.item)) + self.buy_button.setToolTip( + self.recruiter.purchase_tooltip(self.buy_button.isEnabled()) + ) + self.sell_button.setEnabled(self.recruiter.enable_sale(self.item)) + self.sell_button.setToolTip( + self.recruiter.sell_tooltip(self.sell_button.isEnabled()) + ) self.amount_bought.setText(f"{self.pending_units}") -class QRecruitBehaviour: - game_model: GameModel - cp: ControlPoint - purchase_groups: dict[UnitType, PurchaseGroup] - existing_units_labels = None - maximum_units = -1 +class UnitTransactionFrame(QFrame, Generic[TransactionItemType]): BUDGET_FORMAT = "Available Budget: ${:.2f}M" - def __init__(self) -> None: + def __init__( + self, + game_model: GameModel, + purchase_adapter: PurchaseAdapter[TransactionItemType], + ) -> None: + super().__init__() + self.game_model = game_model + self.purchase_adapter = purchase_adapter self.existing_units_labels = {} - self.purchase_groups = {} + self.purchase_groups: dict[ + TransactionItemType, PurchaseGroup[TransactionItemType] + ] = {} self.update_available_budget() - @property - def pending_deliveries(self) -> PendingUnitDeliveries: - return self.cp.pending_unit_deliveries + def current_quantity_of(self, item: TransactionItemType) -> int: + return self.purchase_adapter.current_quantity_of(item) + + def pending_delivery_quantity(self, item: TransactionItemType) -> int: + return self.purchase_adapter.pending_delivery_quantity(item) + + def expected_quantity_next_turn(self, item: TransactionItemType) -> int: + return self.purchase_adapter.expected_quantity_next_turn(item) + + def display_name_of( + self, item: TransactionItemType, multiline: bool = False + ) -> str: + return self.purchase_adapter.name_of(item, multiline) + + def price_of(self, item: TransactionItemType) -> int: + return self.purchase_adapter.price_of(item) @property def budget(self) -> float: @@ -111,20 +144,20 @@ class QRecruitBehaviour: def add_purchase_row( self, - unit_type: UnitType, + item: TransactionItemType, layout: QGridLayout, row: int, ) -> None: exist = QGroupBox() exist.setProperty("style", "buy-box") - exist.setMaximumHeight(36) + exist.setMaximumHeight(72) exist.setMinimumHeight(36) existLayout = QHBoxLayout() exist.setLayout(existLayout) - existing_units = self.cp.base.total_units_of_type(unit_type) + existing_units = self.current_quantity_of(item) - unitName = QLabel(f"{unit_type.name}") + unitName = QLabel(f"{self.display_name_of(item, multiline=True)}") unitName.setSizePolicy( QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) ) @@ -132,17 +165,17 @@ class QRecruitBehaviour: existing_units = QLabel(str(existing_units)) existing_units.setSizePolicy(QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)) - self.existing_units_labels[unit_type] = existing_units + self.existing_units_labels[item] = existing_units - price = QLabel(f"$ {unit_type.price} M") + price = QLabel(f"$ {self.price_of(item)} M") price.setSizePolicy(QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)) - purchase_group = PurchaseGroup(unit_type, self) - self.purchase_groups[unit_type] = purchase_group + purchase_group = PurchaseGroup(item, self) + self.purchase_groups[item] = purchase_group info = QGroupBox() info.setProperty("style", "buy-box") - info.setMaximumHeight(36) + info.setMaximumHeight(72) info.setMinimumHeight(36) infolayout = QHBoxLayout() info.setLayout(infolayout) @@ -151,7 +184,7 @@ class QRecruitBehaviour: unitInfo.setProperty("style", "btn-info") unitInfo.setMinimumSize(16, 16) unitInfo.setMaximumSize(16, 16) - unitInfo.clicked.connect(lambda: self.info(unit_type)) + unitInfo.clicked.connect(lambda: self.info(item)) unitInfo.setSizePolicy(QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)) existLayout.addWidget(unitName) @@ -173,7 +206,9 @@ class QRecruitBehaviour: def update_available_budget(self) -> None: GameUpdateSignal.get_instance().updateBudget(self.game_model.game) - def recruit_handler(self, recruit_type: RecruitType, unit_type: UnitType) -> None: + def recruit_handler( + self, recruit_type: RecruitType, item: TransactionItemType + ) -> None: # Lookup if Keyboard Modifiers were pressed # Shift = 10 times # CTRL = 5 Times @@ -185,50 +220,59 @@ class QRecruitBehaviour: else: amount = 1 - for i in range(amount): - if recruit_type == RecruitType.SELL: - if not self.sell(unit_type): - return - elif recruit_type == RecruitType.BUY: - if not self.buy(unit_type): - return + if recruit_type == RecruitType.SELL: + self.sell(item, amount) + elif recruit_type == RecruitType.BUY: + self.buy(item, amount) - def buy(self, unit_type: UnitType) -> bool: - - if not self.enable_purchase(unit_type): - logging.error(f"Purchase of {unit_type} not allowed at {self.cp.name}") - return False - - self.pending_deliveries.order({unit_type: 1}) - self.budget -= unit_type.price + def post_transaction_update(self) -> None: self.update_purchase_controls() self.update_available_budget() + + def buy(self, item: TransactionItemType, quantity: int) -> bool: + try: + self.purchase_adapter.buy(item, quantity) + except TransactionError as ex: + logging.exception(f"Purchase of {self.display_name_of(item)} failed") + QMessageBox.warning(self, "Purchase failed", str(ex), QMessageBox.Ok) + return False + self.post_transaction_update() return True - def sell(self, unit_type: UnitType) -> bool: - if self.pending_deliveries.available_next_turn(unit_type) > 0: - self.budget += unit_type.price - self.pending_deliveries.sell({unit_type: 1}) - self.update_purchase_controls() - self.update_available_budget() + def sell(self, item: TransactionItemType, quantity: int) -> bool: + try: + self.purchase_adapter.sell(item, quantity) + except TransactionError as ex: + logging.exception(f"Sale of {self.display_name_of(item)} failed") + QMessageBox.warning(self, "Sale failed", str(ex), QMessageBox.Ok) + return False + self.post_transaction_update() return True def update_purchase_controls(self) -> None: for group in self.purchase_groups.values(): group.update_state() - def enable_purchase(self, unit_type: UnitType) -> bool: - return self.budget >= unit_type.price + def enable_purchase(self, item: TransactionItemType) -> bool: + return self.purchase_adapter.can_buy(item) - def enable_sale(self, unit_type: UnitType) -> bool: - return True + def enable_sale(self, item: TransactionItemType) -> bool: + return self.purchase_adapter.can_sell_or_cancel(item) + + @staticmethod + def purchase_tooltip(is_enabled: bool) -> str: + if is_enabled: + return "Buy unit. Use Shift or Ctrl key to buy multiple units at once." + else: + return "Unit can not be bought." + + @staticmethod + def sell_tooltip(is_enabled: bool) -> str: + if is_enabled: + return "Sell unit. Use Shift or Ctrl key to buy multiple units at once." + else: + return "Unit can not be sold." def info(self, unit_type: UnitType) -> None: self.info_window = QUnitInfoWindow(self.game_model.game, unit_type) self.info_window.show() - - def set_maximum_units(self, maximum_units): - """ - Set the maximum number of units that can be bought - """ - self.maximum_units = maximum_units diff --git a/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py b/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py index 9e24e082..d8212ca1 100644 --- a/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py +++ b/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py @@ -1,38 +1,38 @@ -import logging from typing import Set from PySide2.QtCore import Qt from PySide2.QtWidgets import ( - QFrame, QGridLayout, QHBoxLayout, QLabel, - QMessageBox, QScrollArea, QVBoxLayout, QWidget, ) -from dcs.helicopters import helicopter_map from game.dcs.aircrafttype import AircraftType -from game.theater import ControlPoint, ControlPointType +from game.squadrons import Squadron +from game.theater import ControlPoint from qt_ui.models import GameModel from qt_ui.uiconstants import ICONS -from qt_ui.windows.basemenu.QRecruitBehaviour import QRecruitBehaviour +from qt_ui.windows.basemenu.UnitTransactionFrame import UnitTransactionFrame +from game.purchaseadapter import AircraftPurchaseAdapter -class QAircraftRecruitmentMenu(QFrame, QRecruitBehaviour): +class QAircraftRecruitmentMenu(UnitTransactionFrame[Squadron]): def __init__(self, cp: ControlPoint, game_model: GameModel) -> None: - QFrame.__init__(self) + super().__init__( + game_model, + AircraftPurchaseAdapter( + cp, game_model.game.coalition_for(cp.captured), game_model.game + ), + ) self.cp = cp self.game_model = game_model self.purchase_groups = {} self.bought_amount_labels = {} self.existing_units_labels = {} - # Determine maximum number of aircrafts that can be bought - self.set_maximum_units(self.cp.total_aircraft_parking) - self.bought_amount_labels = {} self.existing_units_labels = {} @@ -45,24 +45,14 @@ class QAircraftRecruitmentMenu(QFrame, QRecruitBehaviour): row = 0 unit_types: Set[AircraftType] = set() - for unit_type in self.game_model.game.blue.faction.aircrafts: - if self.cp.is_carrier and not unit_type.carrier_capable: - continue - if self.cp.is_lha and not unit_type.lha_capable: - continue - if ( - self.cp.cptype in [ControlPointType.FOB, ControlPointType.FARP] - and not unit_type.helicopter - ): - continue - unit_types.add(unit_type) - sorted_units = sorted( - unit_types, - key=lambda u: u.name, - ) - for row, unit_type in enumerate(sorted_units): - self.add_purchase_row(unit_type, task_box_layout, row) + for squadron in cp.squadrons: + unit_types.add(squadron.aircraft) + + sorted_squadrons = sorted(cp.squadrons, key=lambda s: (s.aircraft.name, s.name)) + for row, squadron in enumerate(sorted_squadrons): + self.add_purchase_row(squadron, task_box_layout, row) + stretch = QVBoxLayout() stretch.addStretch() task_box_layout.addLayout(stretch, row, 0) @@ -77,63 +67,18 @@ class QAircraftRecruitmentMenu(QFrame, QRecruitBehaviour): main_layout.addWidget(scroll) self.setLayout(main_layout) - def enable_purchase(self, unit_type: AircraftType) -> bool: - if not super().enable_purchase(unit_type): - return False - if not self.cp.can_operate(unit_type): - return False - return True + def sell_tooltip(self, is_enabled: bool) -> str: + if is_enabled: + return "Sell unit. Use Shift or Ctrl key to sell multiple units at once." + else: + return ( + "Can not be sold because either no aircraft are available or are " + "already assigned to a mission." + ) - def enable_sale(self, unit_type: AircraftType) -> bool: - if not self.cp.can_operate(unit_type): - return False - return True - - def buy(self, unit_type: AircraftType) -> bool: - if self.maximum_units > 0: - if self.cp.unclaimed_parking(self.game_model.game) <= 0: - logging.debug(f"No space for additional aircraft at {self.cp}.") - QMessageBox.warning( - self, - "No space for additional aircraft", - f"There is no parking space left at {self.cp.name} to accommodate " - "another plane.", - QMessageBox.Ok, - ) - return False - # If we change our mind about selling, we want the aircraft to be put - # back in the inventory immediately. - elif self.pending_deliveries.units.get(unit_type, 0) < 0: - global_inventory = self.game_model.game.aircraft_inventory - inventory = global_inventory.for_control_point(self.cp) - inventory.add_aircraft(unit_type, 1) - - super().buy(unit_type) + def post_transaction_update(self) -> None: + super().post_transaction_update() self.hangar_status.update_label() - return True - - def sell(self, unit_type: AircraftType) -> bool: - # Don't need to remove aircraft from the inventory if we're canceling - # orders. - if self.pending_deliveries.units.get(unit_type, 0) <= 0: - global_inventory = self.game_model.game.aircraft_inventory - inventory = global_inventory.for_control_point(self.cp) - try: - inventory.remove_aircraft(unit_type, 1) - except ValueError: - QMessageBox.critical( - self, - "Could not sell aircraft", - f"Attempted to sell one {unit_type} at {self.cp.name} " - "but none are available. Are all aircraft currently " - "assigned to a mission?", - QMessageBox.Ok, - ) - return False - super().sell(unit_type) - self.hangar_status.update_label() - - return True class QHangarStatus(QHBoxLayout): diff --git a/qt_ui/windows/basemenu/ground_forces/QArmorRecruitmentMenu.py b/qt_ui/windows/basemenu/ground_forces/QArmorRecruitmentMenu.py index 898d1cc4..77e2af8f 100644 --- a/qt_ui/windows/basemenu/ground_forces/QArmorRecruitmentMenu.py +++ b/qt_ui/windows/basemenu/ground_forces/QArmorRecruitmentMenu.py @@ -1,30 +1,27 @@ from PySide2.QtCore import Qt -from PySide2.QtWidgets import ( - QFrame, - QGridLayout, - QScrollArea, - QVBoxLayout, - QWidget, -) +from PySide2.QtWidgets import QGridLayout, QScrollArea, QVBoxLayout, QWidget from game.dcs.groundunittype import GroundUnitType from game.theater import ControlPoint from qt_ui.models import GameModel -from qt_ui.windows.basemenu.QRecruitBehaviour import QRecruitBehaviour +from qt_ui.windows.basemenu.UnitTransactionFrame import UnitTransactionFrame +from game.purchaseadapter import GroundUnitPurchaseAdapter -class QArmorRecruitmentMenu(QFrame, QRecruitBehaviour): +class QArmorRecruitmentMenu(UnitTransactionFrame[GroundUnitType]): def __init__(self, cp: ControlPoint, game_model: GameModel): - QFrame.__init__(self) + super().__init__( + game_model, + GroundUnitPurchaseAdapter( + cp, game_model.game.coalition_for(cp.captured), game_model.game + ), + ) self.cp = cp self.game_model = game_model self.purchase_groups = {} self.bought_amount_labels = {} self.existing_units_labels = {} - self.init_ui() - - def init_ui(self): main_layout = QVBoxLayout() scroll_content = QWidget() @@ -50,11 +47,3 @@ class QArmorRecruitmentMenu(QFrame, QRecruitBehaviour): scroll.setWidget(scroll_content) main_layout.addWidget(scroll) self.setLayout(main_layout) - - def enable_purchase(self, unit_type: GroundUnitType) -> bool: - if not super().enable_purchase(unit_type): - return False - return self.cp.has_ground_unit_source(self.game_model.game) - - def enable_sale(self, unit_type: GroundUnitType) -> bool: - return self.pending_deliveries.pending_orders(unit_type) > 0 diff --git a/qt_ui/windows/basemenu/intel/QIntelInfo.py b/qt_ui/windows/basemenu/intel/QIntelInfo.py index c8bf03e8..d73682db 100644 --- a/qt_ui/windows/basemenu/intel/QIntelInfo.py +++ b/qt_ui/windows/basemenu/intel/QIntelInfo.py @@ -26,7 +26,7 @@ class QIntelInfo(QFrame): intel_layout = QVBoxLayout() units_by_task: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int)) - for unit_type, count in self.cp.base.aircraft.items(): + for unit_type, count in self.cp.allocated_aircraft(game).present.items(): if count: task_type = unit_type.dcs_unit_type.task_default.name units_by_task[task_type][unit_type.name] += count diff --git a/qt_ui/windows/intel.py b/qt_ui/windows/intel.py index 64b539d0..288b87fe 100644 --- a/qt_ui/windows/intel.py +++ b/qt_ui/windows/intel.py @@ -77,14 +77,15 @@ class AircraftIntelLayout(IntelTableLayout): total = 0 for control_point in game.theater.control_points_for(player): - base = control_point.base - total += base.total_aircraft - if not base.total_aircraft: + allocation = control_point.allocated_aircraft(game) + base_total = allocation.total_present + total += base_total + if not base_total: continue - self.add_header(f"{control_point.name} ({base.total_aircraft})") - for airframe in sorted(base.aircraft, key=lambda k: k.name): - count = base.aircraft[airframe] + self.add_header(f"{control_point.name} ({base_total})") + for airframe in sorted(allocation.present, key=lambda k: k.name): + count = allocation.present[airframe] if not count: continue self.add_row(f" {airframe.name}", count) diff --git a/qt_ui/windows/mission/QPackageDialog.py b/qt_ui/windows/mission/QPackageDialog.py index c86987ae..2f83c85b 100644 --- a/qt_ui/windows/mission/QPackageDialog.py +++ b/qt_ui/windows/mission/QPackageDialog.py @@ -177,7 +177,6 @@ class QPackageDialog(QDialog): def add_flight(self, flight: Flight) -> None: """Adds the new flight to the package.""" - self.game.aircraft_inventory.claim_for_flight(flight) self.package_model.add_flight(flight) planner = FlightPlanBuilder( self.package_model.package, self.game.blue, self.game.theater @@ -251,8 +250,7 @@ class QNewPackageDialog(QPackageDialog): def on_cancel(self) -> None: super().on_cancel() for flight in self.package_model.package.flights: - self.game.aircraft_inventory.return_from_flight(flight) - flight.clear_roster() + flight.return_pilots_and_aircraft() class QEditPackageDialog(QPackageDialog): diff --git a/qt_ui/windows/mission/flight/QFlightCreator.py b/qt_ui/windows/mission/flight/QFlightCreator.py index 3c0a1e74..cc465422 100644 --- a/qt_ui/windows/mission/flight/QFlightCreator.py +++ b/qt_ui/windows/mission/flight/QFlightCreator.py @@ -14,7 +14,7 @@ from PySide2.QtWidgets import ( from dcs.unittype import FlyingType from game import Game -from game.squadrons import Squadron +from game.squadrons.squadron import Squadron from game.theater import ControlPoint, OffMapSpawn from gen.ato import Package from gen.flights.flight import Flight, FlightRoster @@ -24,7 +24,6 @@ from qt_ui.widgets.QLabeledWidget import QLabeledWidget from qt_ui.widgets.combos.QAircraftTypeSelector import QAircraftTypeSelector from qt_ui.widgets.combos.QArrivalAirfieldSelector import QArrivalAirfieldSelector from qt_ui.widgets.combos.QFlightTypeComboBox import QFlightTypeComboBox -from qt_ui.widgets.combos.QOriginAirfieldSelector import QOriginAirfieldSelector from qt_ui.windows.mission.flight.SquadronSelector import SquadronSelector from qt_ui.windows.mission.flight.settings.QFlightSlotEditor import FlightRosterEditor @@ -34,6 +33,7 @@ class QFlightCreator(QDialog): def __init__(self, game: Game, package: Package, parent=None) -> None: super().__init__(parent=parent) + self.setMinimumWidth(400) self.game = game self.package = package @@ -51,7 +51,7 @@ class QFlightCreator(QDialog): layout.addLayout(QLabeledWidget("Task:", self.task_selector)) self.aircraft_selector = QAircraftTypeSelector( - self.game.aircraft_inventory.available_types_for_player, + self.game.blue.air_wing.available_aircraft_types, self.task_selector.currentData(), ) self.aircraft_selector.setCurrentIndex(0) @@ -66,22 +66,6 @@ class QFlightCreator(QDialog): self.squadron_selector.setCurrentIndex(0) layout.addLayout(QLabeledWidget("Squadron:", self.squadron_selector)) - self.departure = QOriginAirfieldSelector( - self.game.aircraft_inventory, - [cp for cp in game.theater.controlpoints if cp.captured], - self.aircraft_selector.currentData(), - ) - self.departure.availability_changed.connect(self.update_max_size) - self.departure.currentIndexChanged.connect(self.on_departure_changed) - layout.addLayout(QLabeledWidget("Departure:", self.departure)) - - self.arrival = QArrivalAirfieldSelector( - [cp for cp in game.theater.controlpoints if cp.captured], - self.aircraft_selector.currentData(), - "Same as departure", - ) - layout.addLayout(QLabeledWidget("Arrival:", self.arrival)) - self.divert = QArrivalAirfieldSelector( [cp for cp in game.theater.controlpoints if cp.captured], self.aircraft_selector.currentData(), @@ -90,7 +74,7 @@ class QFlightCreator(QDialog): layout.addLayout(QLabeledWidget("Divert:", self.divert)) self.flight_size_spinner = QFlightSizeSpinner() - self.update_max_size(self.departure.available) + self.update_max_size(self.squadron_selector.aircraft_available) layout.addLayout(QLabeledWidget("Size:", self.flight_size_spinner)) squadron = self.squadron_selector.currentData() @@ -144,8 +128,6 @@ class QFlightCreator(QDialog): self.setLayout(layout) - self.on_departure_changed(self.departure.currentIndex()) - def reject(self) -> None: super().reject() # Clear the roster to return pilots to the pool. @@ -161,25 +143,19 @@ class QFlightCreator(QDialog): def verify_form(self) -> Optional[str]: aircraft: Optional[Type[FlyingType]] = self.aircraft_selector.currentData() squadron: Optional[Squadron] = self.squadron_selector.currentData() - origin: Optional[ControlPoint] = self.departure.currentData() - arrival: Optional[ControlPoint] = self.arrival.currentData() divert: Optional[ControlPoint] = self.divert.currentData() size: int = self.flight_size_spinner.value() if aircraft is None: return "You must select an aircraft type." if squadron is None: return "You must select a squadron." - if not origin.captured: - return f"{origin.name} is not owned by your coalition." - if arrival is not None and not arrival.captured: - return f"{arrival.name} is not owned by your coalition." if divert is not None and not divert.captured: return f"{divert.name} is not owned by your coalition." - available = origin.base.aircraft.get(aircraft, 0) + available = squadron.untasked_aircraft if not available: - return f"{origin.name} has no {aircraft.id} available." + return f"{squadron} has no aircraft available." if size > available: - return f"{origin.name} has only {available} {aircraft.id} available." + return f"{squadron} has only {available} aircraft available." if size <= 0: return f"Flight must have at least one aircraft." if self.custom_name_text and "|" in self.custom_name_text: @@ -194,14 +170,9 @@ class QFlightCreator(QDialog): task = self.task_selector.currentData() squadron = self.squadron_selector.currentData() - origin = self.departure.currentData() - arrival = self.arrival.currentData() divert = self.divert.currentData() roster = self.roster_editor.roster - if arrival is None: - arrival = origin - flight = Flight( self.package, self.country, @@ -211,8 +182,8 @@ class QFlightCreator(QDialog): roster.max_size, task, self.start_type.currentText(), - origin, - arrival, + squadron.location, + squadron.location, divert, custom_name=self.custom_name_text, roster=roster, @@ -228,11 +199,9 @@ class QFlightCreator(QDialog): self.task_selector.currentData(), new_aircraft ) self.departure.change_aircraft(new_aircraft) - self.arrival.change_aircraft(new_aircraft) self.divert.change_aircraft(new_aircraft) - def on_departure_changed(self, index: int) -> None: - departure = self.departure.itemData(index) + def on_departure_changed(self, departure: ControlPoint) -> None: if isinstance(departure, OffMapSpawn): previous_type = self.start_type.currentText() if previous_type != "In Flight": @@ -248,12 +217,12 @@ class QFlightCreator(QDialog): def on_task_changed(self, index: int) -> None: task = self.task_selector.itemData(index) self.aircraft_selector.update_items( - task, self.game.aircraft_inventory.available_types_for_player + task, self.game.blue.air_wing.available_aircraft_types ) self.squadron_selector.update_items(task, self.aircraft_selector.currentData()) def on_squadron_changed(self, index: int) -> None: - squadron = self.squadron_selector.itemData(index) + squadron: Optional[Squadron] = self.squadron_selector.itemData(index) # Clear the roster first so we return the pilots to the pool. This way if we end # up repopulating from the same squadron we'll get the same pilots back. self.roster_editor.replace(None) @@ -261,6 +230,7 @@ class QFlightCreator(QDialog): self.roster_editor.replace( FlightRoster(squadron, self.flight_size_spinner.value()) ) + self.on_departure_changed(squadron.location) def update_max_size(self, available: int) -> None: aircraft = self.aircraft_selector.currentData() diff --git a/qt_ui/windows/mission/flight/SquadronSelector.py b/qt_ui/windows/mission/flight/SquadronSelector.py index e9b7ae7f..290e821b 100644 --- a/qt_ui/windows/mission/flight/SquadronSelector.py +++ b/qt_ui/windows/mission/flight/SquadronSelector.py @@ -1,10 +1,10 @@ """Combo box for selecting squadrons.""" -from typing import Type, Optional +from typing import Optional from PySide2.QtWidgets import QComboBox -from dcs.unittype import FlyingType -from game.squadrons import AirWing +from game.dcs.aircrafttype import AircraftType +from game.squadrons.airwing import AirWing from gen.flights.flight import FlightType @@ -15,7 +15,7 @@ class SquadronSelector(QComboBox): self, air_wing: AirWing, task: Optional[FlightType], - aircraft: Optional[Type[FlyingType]], + aircraft: Optional[AircraftType], ) -> None: super().__init__() self.air_wing = air_wing @@ -24,8 +24,15 @@ class SquadronSelector(QComboBox): self.setSizeAdjustPolicy(self.AdjustToContents) self.update_items(task, aircraft) + @property + def aircraft_available(self) -> int: + squadron = self.currentData() + if squadron is None: + return 0 + return squadron.untasked_aircraft + def update_items( - self, task: Optional[FlightType], aircraft: Optional[Type[FlyingType]] + self, task: Optional[FlightType], aircraft: Optional[AircraftType] ) -> None: current_squadron = self.currentData() self.blockSignals(True) @@ -41,12 +48,12 @@ class SquadronSelector(QComboBox): return for squadron in self.air_wing.squadrons_for(aircraft): - if task in squadron.mission_types: - self.addItem(f"{squadron}", squadron) + if task in squadron.mission_types and squadron.untasked_aircraft: + self.addItem(f"{squadron.location}: {squadron}", squadron) if self.count() == 0: self.addItem("No capable aircraft available", None) return if current_squadron is not None: - self.setCurrentText(f"{current_squadron}") + self.setCurrentText(f"{current_squadron.location}: {current_squadron}") diff --git a/qt_ui/windows/mission/flight/settings/QFlightSlotEditor.py b/qt_ui/windows/mission/flight/settings/QFlightSlotEditor.py index 3db6ab2d..665e45ae 100644 --- a/qt_ui/windows/mission/flight/settings/QFlightSlotEditor.py +++ b/qt_ui/windows/mission/flight/settings/QFlightSlotEditor.py @@ -14,7 +14,7 @@ from PySide2.QtWidgets import ( ) from game import Game -from game.squadrons import Pilot +from game.squadrons.pilot import Pilot from gen.flights.flight import Flight, FlightRoster from qt_ui.models import PackageModel @@ -195,8 +195,7 @@ class QFlightSlotEditor(QGroupBox): self.package_model = package_model self.flight = flight self.game = game - self.inventory = self.game.aircraft_inventory.for_control_point(flight.from_cp) - available = self.inventory.available(self.flight.unit_type) + available = self.flight.squadron.untasked_aircraft max_count = self.flight.count + available if max_count > 4: max_count = 4 @@ -225,21 +224,18 @@ class QFlightSlotEditor(QGroupBox): def _changed_aircraft_count(self): old_count = self.flight.count new_count = int(self.aircraft_count_spinner.value()) - self.game.aircraft_inventory.return_from_flight(self.flight) - self.flight.resize(new_count) try: - self.game.aircraft_inventory.claim_for_flight(self.flight) + self.flight.resize(new_count) except ValueError: # The UI should have prevented this, but if we ran out of aircraft # then roll back the inventory change. difference = new_count - self.flight.count - available = self.inventory.available(self.flight.unit_type) + available = self.flight.squadron.untasked_aircraft logging.error( f"Could not add {difference} additional aircraft to " f"{self.flight} because {self.flight.departure} has only " f"{available} {self.flight.unit_type} remaining" ) - self.game.aircraft_inventory.claim_for_flight(self.flight) self.flight.resize(old_count) return self.roster_editor.resize(new_count) diff --git a/qt_ui/windows/newgame/QCampaignList.py b/qt_ui/windows/newgame/QCampaignList.py index 1d74e64a..a5fcf305 100644 --- a/qt_ui/windows/newgame/QCampaignList.py +++ b/qt_ui/windows/newgame/QCampaignList.py @@ -1,116 +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 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_json(cls, path: Path) -> Campaign: - with path.open() as campaign_file: - 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_json(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_json(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): @@ -140,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..0b1f4539 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()) @@ -116,10 +113,12 @@ class NewGameWizard(QtWidgets.QWizard): blue_faction = self.faction_selection_page.selected_blue_faction red_faction = self.faction_selection_page.selected_red_faction + theater = campaign.load_theater() generator = GameGenerator( blue_faction, red_faction, - campaign.load_theater(), + theater, + campaign.load_air_wing_config(theater), settings, generator_settings, mod_settings, @@ -369,6 +368,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( diff --git a/qt_ui/windows/settings/QSettingsWindow.py b/qt_ui/windows/settings/QSettingsWindow.py index 188963d7..8cd86618 100644 --- a/qt_ui/windows/settings/QSettingsWindow.py +++ b/qt_ui/windows/settings/QSettingsWindow.py @@ -230,7 +230,7 @@ class PilotSettingsBox(QGroupBox): ) enable_squadron_pilot_limits_label = QLabel( - "Enable per-squadron pilot limtits (WIP)" + "Enable per-squadron pilot limits (WIP)" ) enable_squadron_pilot_limits_label.setToolTip(enable_squadron_pilot_limits_info) enable_squadron_pilot_limits = QCheckBox() @@ -517,6 +517,18 @@ class QSettingsWindow(QDialog): self.ext_views.setChecked(self.game.settings.external_views_allowed) self.ext_views.toggled.connect(self.applySettings) + self.battleDamageAssessment = QComboBox() + self.battleDamageAssessment.addItem("Player preference", None) + self.battleDamageAssessment.addItem("Enforced on", True) + self.battleDamageAssessment.addItem("Enforced off", False) + if self.game.settings.battle_damage_assessment is None: + self.battleDamageAssessment.setCurrentIndex(0) + elif self.game.settings.battle_damage_assessment is True: + self.battleDamageAssessment.setCurrentIndex(1) + else: + self.battleDamageAssessment.setCurrentIndex(2) + self.battleDamageAssessment.currentIndexChanged.connect(self.applySettings) + def set_invulnerable_player_pilots(checked: bool) -> None: self.game.settings.invulnerable_player_pilots = checked @@ -568,6 +580,14 @@ class QSettingsWindow(QDialog): ) self.missionRestrictionsLayout.addWidget(QLabel("Allow external views"), 2, 0) self.missionRestrictionsLayout.addWidget(self.ext_views, 2, 1, Qt.AlignRight) + + self.missionRestrictionsLayout.addWidget( + QLabel("Battle damage assessment"), 3, 0 + ) + self.missionRestrictionsLayout.addWidget( + self.battleDamageAssessment, 3, 1, Qt.AlignRight + ) + self.missionRestrictionsSettings.setLayout(self.missionRestrictionsLayout) self.difficultyLayout.addWidget(self.missionRestrictionsSettings) @@ -909,6 +929,9 @@ class QSettingsWindow(QDialog): self.mapVisibiitySelection.currentData() ) self.game.settings.external_views_allowed = self.ext_views.isChecked() + self.game.settings.battle_damage_assessment = ( + self.battleDamageAssessment.currentData() + ) self.game.settings.generate_marks = self.generate_marks.isChecked() self.game.settings.never_delay_player_flights = ( self.never_delay_players.isChecked() diff --git a/resources/campaigns/Operation_Atilla.json b/resources/campaigns/Operation_Atilla.json deleted file mode 100644 index 313f178e..00000000 --- a/resources/campaigns/Operation_Atilla.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "Syria - Operation Atilla", - "theater": "Syria", - "authors": "Malakhit", - "recommended_player_faction": "Turkey 2005", - "recommended_enemy_faction": "Greece 2005", - "description": "

This is based on the Turkish invasion of Cyprus, and the Greek defense of the island. You must make sure to keep your beachhead at all costs, otherwise reclaiming it will be tricky. It is recommended to reduce the per-turn income rate for both factions in this scenario due to the large oil depots both sides have - setting it to around 15-20% is probably reasonable.

", - "version": "7.0", - "miz": "Operation_Atilla.miz", - "performance": 2 -} \ No newline at end of file diff --git a/resources/campaigns/Operation_Atilla.miz b/resources/campaigns/Operation_Atilla.miz deleted file mode 100644 index e68aa418..00000000 Binary files a/resources/campaigns/Operation_Atilla.miz and /dev/null differ diff --git a/resources/campaigns/Russian_Intervention_2015.json b/resources/campaigns/Russian_Intervention_2015.json deleted file mode 100644 index fd25fd3c..00000000 --- a/resources/campaigns/Russian_Intervention_2015.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "Syria - Russian Intervention 2015", - "theater": "Syria", - "authors": "Malakhit", - "recommended_player_faction": "Russia 2010", - "recommended_enemy_faction": "Insurgents (Hard)", - "description": "

This short campaign is loosely based on the 2015 Russian military intervention in Syria. It should be perfect for COIN operations, especially with the Hind. Not designed for campaign inversion.

", - "version": "7.0", - "miz": "Russian_Intervention_2015.miz", - "performance": 1 -} \ No newline at end of file diff --git a/resources/campaigns/Russian_Intervention_2015.miz b/resources/campaigns/Russian_Intervention_2015.miz deleted file mode 100644 index e1797983..00000000 Binary files a/resources/campaigns/Russian_Intervention_2015.miz and /dev/null differ diff --git a/resources/campaigns/battle_of_abu_dhabi.json b/resources/campaigns/battle_of_abu_dhabi.json deleted file mode 100644 index 5d6c25ca..00000000 --- a/resources/campaigns/battle_of_abu_dhabi.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "Persian Gulf - Battle of Abu Dhabi", - "theater": "Persian Gulf", - "authors": "Colonel Panic", - "recommended_player_faction": "Iran 2015", - "recommended_enemy_faction": "United Arab Emirates 2015", - "description": "

You have managed to establish a foothold near Ras Al Khaima. Continue pushing south.

", - "miz": "battle_of_abu_dhabi.miz", - "performance": 2, - "version": "8.0" -} \ No newline at end of file diff --git a/resources/campaigns/battle_of_abu_dhabi.yaml b/resources/campaigns/battle_of_abu_dhabi.yaml new file mode 100644 index 00000000..ebd2d5d4 --- /dev/null +++ b/resources/campaigns/battle_of_abu_dhabi.yaml @@ -0,0 +1,10 @@ +--- +name: Persian Gulf - Battle of Abu Dhabi +theater: Persian Gulf +authors: Colonel Panic +recommended_player_faction: Iran 2015 +recommended_enemy_faction: United Arab Emirates 2015 +description:

You have managed to establish a foothold near Ras Al Khaima. Continue pushing south.

+miz: battle_of_abu_dhabi.miz +performance: 2 +version": "8.0" \ No newline at end of file diff --git a/resources/campaigns/black_sea.json b/resources/campaigns/black_sea.json deleted file mode 100644 index 94cc5e02..00000000 --- a/resources/campaigns/black_sea.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "Caucasus - Black Sea", - "theater": "Caucasus", - "authors": "Colonel Panic", - "description": "

A medium sized theater with bases along the coast of the Black Sea.

", - "miz": "black_sea.miz", - "performance": 2, - "version": "8.0" -} \ No newline at end of file diff --git a/resources/campaigns/black_sea.miz b/resources/campaigns/black_sea.miz index e32a1fb2..97500040 100644 Binary files a/resources/campaigns/black_sea.miz and b/resources/campaigns/black_sea.miz differ diff --git a/resources/campaigns/black_sea.yaml b/resources/campaigns/black_sea.yaml new file mode 100644 index 00000000..f7452d70 --- /dev/null +++ b/resources/campaigns/black_sea.yaml @@ -0,0 +1,151 @@ +--- +name: Caucasus - Black Sea +theater: Caucasus +authors: Colonel Panic +description:

A medium sized theater with bases along the coast of the Black Sea.

+miz: black_sea.miz +performance: 2, +version: "9.0" +squadrons: + # Anapa-Vityazevo + 12: + - primary: BARCAP + aircraft: + - Su-30 Flanker-C + - Su-27 Flanker-B + - primary: AEW&C + aircraft: + - A-50 + - primary: Refueling + aircraft: + - IL-78M + - primary: Transport + aircraft: + - IL-78MD + - primary: Strike + secondary: air-to-ground + aircraft: + - Tu-160 Blackjack + # Krasnodar-Center + 13: + - primary: BARCAP + secondary: air-to-air + aircraft: + - MiG-31 Foxhound + - MiG-25PD Foxbat-E + - primary: SEAD + secondary: any + - primary: DEAD + secondary: any + # Maykop-Khanskaya + 16: + - primary: CAS + secondary: air-to-ground + aircraft: + - Su-25 Frogfoot + - primary: BAI + secondary: air-to-ground + aircraft: + - Su-34 Fullback + - Su-24M Fencer-D + - primary: DEAD + secondary: air-to-ground + - primary: CAS + secondary: air-to-ground + aircraft: + - Mi-24P Hind-F + - Mi-24P Hind-E + # Senaki-Kholki + 23: + - primary: CAS + secondary: air-to-ground + aircraft: + - A-10C Thunderbolt II (Suite 7) + - A-10C Thunderbolt II (Suite 3) + - primary: BAI + secondary: air-to-ground + aircraft: + - F-15E Strike Eagle + - primary: DEAD + secondary: air-to-ground + aircraft: + - F-16CM Fighting Falcon (Block 50) + - primary: Transport + aircraft: + - UH-60A + # Kobuleti + 24: + - primary: BARCAP + secondary: air-to-air + aircraft: + - F-15C Eagle + - primary: SEAD + secondary: any + aircraft: + - F-16CM Fighting Falcon (Block 50) + - primary: DEAD + secondary: any + aircraft: + - F-16CM Fighting Falcon (Block 50) + # Kutaisi + 25: + - primary: BARCAP + aircraft: + - F-15C Eagle + - primary: AEW&C + aircraft: + - E-3A + - primary: Refueling + aircraft: + - KC-135 Stratotanker + - primary: Transport + aircraft: + - C-17A + - primary: Strike + secondary: air-to-ground + aircraft: + - B-1B Lancer + Blue CV: + - primary: BARCAP + secondary: air-to-air + aircraft: + - F-14B Tomcat + - primary: BARCAP + secondary: any + aircraft: + - F-14B Tomcat + - primary: Strike + secondary: any + aircraft: + - F/A-18C Hornet (Lot 20) + - primary: BAI + secondary: any + aircraft: + - F/A-18C Hornet (Lot 20) + - primary: Refueling + aircraft: + - S-3B Tanker + Blue LHA: + - primary: BAI + secondary: air-to-ground + aircraft: + - AV-8B Harrier II Night Attack + - primary: CAS + secondary: air-to-ground + aircraft: + - UH-1H Iroquois + Red CV: + - primary: BARCAP + secondary: air-to-air + - primary: BARCAP + secondary: any + - primary: Strike + secondary: any + - primary: BAI + secondary: any + - primary: Refueling + Red LHA: + - primary: BAI + secondary: air-to-ground + - primary: CAS + secondary: air-to-ground \ No newline at end of file diff --git a/resources/campaigns/guam.json b/resources/campaigns/guam.json deleted file mode 100644 index 17b312a1..00000000 --- a/resources/campaigns/guam.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "Mariana Islands - Battle for Guam", - "theater": "MarianaIslands", - "authors": "Khopa", - "recommended_player_faction": "USA 2005", - "recommended_enemy_faction": "China 2010", - "description": "

As USA, repel a Chinese invasion of Guam Island.

", - "miz": "guam.miz", - "performance": 1, - "version": "7.0" -} diff --git a/resources/campaigns/guam.miz b/resources/campaigns/guam.miz deleted file mode 100644 index b40ec246..00000000 Binary files a/resources/campaigns/guam.miz and /dev/null differ diff --git a/resources/campaigns/humble_helper.json b/resources/campaigns/humble_helper.json index e89a183e..37cfaed9 100644 --- a/resources/campaigns/humble_helper.json +++ b/resources/campaigns/humble_helper.json @@ -7,5 +7,5 @@ "description": "

In this scenario, you start in Israel in an high intensity conflict with Syria, backed by a Russian Expeditiary Force. Your goal is to take the heavily fortified city of Damascus, as fast as you can. The longer you wait, the more resources the enemy can pump into the defense of the city or even might try to take chunks of Israel. ATTENTION: CAMPAIGN INVERTING IS NOT YET IMPLEMENTED!!! Feedback: @Headiii in the DCSLiberation Discord

", "miz": "humble_helper.miz", "performance": 1, - "version": "7.0" + "version": "8.0" } diff --git a/resources/campaigns/humble_helper.miz b/resources/campaigns/humble_helper.miz index 8053703a..1c71133e 100644 Binary files a/resources/campaigns/humble_helper.miz and b/resources/campaigns/humble_helper.miz differ diff --git a/resources/campaigns/syria_full_map.json b/resources/campaigns/syria_full_map.json index f0393d24..d33b26d9 100644 --- a/resources/campaigns/syria_full_map.json +++ b/resources/campaigns/syria_full_map.json @@ -7,5 +7,5 @@ "description": "

A long campaign across the syria map, strap in.

Each turn after planning missions, enable culling to ensure correct culling behaviour.

", "miz": "syria_full_map.miz", "performance": 3, - "version": "7.0" + "version": "8.0" } \ No newline at end of file diff --git a/resources/campaigns/syria_full_map.miz b/resources/campaigns/syria_full_map.miz index f36953ab..fd9aad70 100644 Binary files a/resources/campaigns/syria_full_map.miz and b/resources/campaigns/syria_full_map.miz differ diff --git a/resources/units/aircraft/F-15C.yaml b/resources/units/aircraft/F-15C.yaml index 36f39a11..601148bb 100644 --- a/resources/units/aircraft/F-15C.yaml +++ b/resources/units/aircraft/F-15C.yaml @@ -1,4 +1,5 @@ -description: The F-15 has often been labeled as the greatest U.S. fighter aircraft +description: + The F-15 has often been labeled as the greatest U.S. fighter aircraft from the 1970s until the early 21st century. The F-15C is a pure fighter with outstanding performance and has scored over 100 air-to-air victories without suffering any confirmed losses. @@ -7,6 +8,7 @@ manufacturer: McDonnell Douglas origin: USA price: 20 role: Air-Superiority Fighter +max_range: 400 variants: F-15C Eagle: {} F-15J Eagle: {} diff --git a/resources/units/aircraft/F-15E.yaml b/resources/units/aircraft/F-15E.yaml index 3a80a4cb..ecba91bd 100644 --- a/resources/units/aircraft/F-15E.yaml +++ b/resources/units/aircraft/F-15E.yaml @@ -1,4 +1,5 @@ -description: The F-15 has often been labeled as the greatest U.S. fighter aircraft +description: + The F-15 has often been labeled as the greatest U.S. fighter aircraft from the 1970s until the early 21st century. The F-15E is a multirole fighter and exceeds in CAS operations. It served worldwide without suffering any confirmed losses. introduced: 1988 @@ -6,5 +7,6 @@ manufacturer: McDonnell Douglas origin: USA price: 24 role: Multirole Strike Fighter +max_range: 300 variants: F-15E Strike Eagle: {} diff --git a/resources/units/aircraft/F-16C_50.yaml b/resources/units/aircraft/F-16C_50.yaml index adefe831..441acf94 100644 --- a/resources/units/aircraft/F-16C_50.yaml +++ b/resources/units/aircraft/F-16C_50.yaml @@ -28,6 +28,16 @@ origin: USA price: 22 role: Multirole Fighter max_range: 200 +fuel: + # Parking 44 to RWY 06L at Anderson AFB. + taxi: 200 + # AB takeoff to 350/0.75, reduce to MIL and maintain 350/0.75 to 25k ft. + climb_ppm: 28.33 + # 0.85 mach for 100NM. + cruise_ppm: 12 + # MIL for 100NM. Occasional AB use. + combat_ppm: 26 + min_safe: 1000 variants: F-16CM Fighting Falcon (Block 50): {} F-2A: {} diff --git a/resources/units/aircraft/MiG-25PD.yaml b/resources/units/aircraft/MiG-25PD.yaml index ed5b0e43..85c2afd5 100644 --- a/resources/units/aircraft/MiG-25PD.yaml +++ b/resources/units/aircraft/MiG-25PD.yaml @@ -1,4 +1,5 @@ -description: "The Mikoyan-Gurevich MiG-25 (Russian: \u041C\u0438\u043A\u043E\u044F\ +description: + "The Mikoyan-Gurevich MiG-25 (Russian: \u041C\u0438\u043A\u043E\u044F\ \u043D \u0438 \u0413\u0443\u0440\u0435\u0432\u0438\u0447 \u041C\u0438\u0413-25;\ \ NATO reporting name: Foxbat) is a supersonic interceptor and reconnaissance aircraft\ \ that was among the fastest military aircraft to enter service. It was designed\ @@ -24,5 +25,6 @@ manufacturer: Mikoyan-Gurevich origin: USSR/Russia price: 17 role: Interceptor +max_range: 500 variants: MiG-25PD Foxbat-E: {} diff --git a/resources/units/aircraft/MiG-25RBT.yaml b/resources/units/aircraft/MiG-25RBT.yaml index 99d65253..15ffc5ca 100644 --- a/resources/units/aircraft/MiG-25RBT.yaml +++ b/resources/units/aircraft/MiG-25RBT.yaml @@ -1,4 +1,5 @@ -description: "The Mikoyan-Gurevich MiG-25 (Russian: \u041C\u0438\u043A\u043E\u044F\ +description: + "The Mikoyan-Gurevich MiG-25 (Russian: \u041C\u0438\u043A\u043E\u044F\ \u043D \u0438 \u0413\u0443\u0440\u0435\u0432\u0438\u0447 \u041C\u0438\u0413-25;\ \ NATO reporting name: Foxbat) is a supersonic interceptor and reconnaissance aircraft\ \ that was among the fastest military aircraft to enter service. It was designed\ @@ -24,5 +25,6 @@ manufacturer: Mikoyan-Gurevich origin: USSR/Russia price: 17 role: Strike Fighter +max_range: 500 variants: MiG-25RBT Foxbat-B: {} diff --git a/resources/units/aircraft/MiG-31.yaml b/resources/units/aircraft/MiG-31.yaml index 753404fd..f38d54dc 100644 --- a/resources/units/aircraft/MiG-31.yaml +++ b/resources/units/aircraft/MiG-31.yaml @@ -1,4 +1,5 @@ -description: "The Mikoyan MiG-31 (Russian: \u041C\u0438\u043A\u043E\u044F\u043D \u041C\ +description: + "The Mikoyan MiG-31 (Russian: \u041C\u0438\u043A\u043E\u044F\u043D \u041C\ \u0438\u0413-31; NATO reporting name: Foxhound) is a supersonic interceptor aircraft\ \ that was developed for use by the Soviet Air Forces. The aircraft was designed\ \ by the Mikoyan design bureau as a replacement for the earlier MiG-25 \"Foxbat\"\ @@ -14,5 +15,6 @@ manufacturer: Mikoyan origin: USSR/Russia price: 24 role: Interceptor +max_range: 800 variants: MiG-31 Foxhound: {} diff --git a/resources/units/aircraft/Su-25.yaml b/resources/units/aircraft/Su-25.yaml index 7287262f..8c425bc3 100644 --- a/resources/units/aircraft/Su-25.yaml +++ b/resources/units/aircraft/Su-25.yaml @@ -1,5 +1,6 @@ always_keeps_gun: true -description: The Su-25 'Grach' (Rook), NATO callsigned 'Frogfoot', is a dedicated +description: + The Su-25 'Grach' (Rook), NATO callsigned 'Frogfoot', is a dedicated strike attack aircraft designed for the close air support and anti-tank roles. The Su-25 has seen combat in several conflicts during its more than 30 years in service. The Su-25 combines excellent pilot protection and high speed compared to most dedicated @@ -10,5 +11,6 @@ manufacturer: Sukhoi origin: USSR/Russia price: 11 role: Close Air Support/Attack +max_range: 200 variants: Su-25 Frogfoot: {} diff --git a/resources/units/aircraft/Su-25T.yaml b/resources/units/aircraft/Su-25T.yaml index b4523739..0059c2ca 100644 --- a/resources/units/aircraft/Su-25T.yaml +++ b/resources/units/aircraft/Su-25T.yaml @@ -1,5 +1,6 @@ always_keeps_gun: true -description: The Su-25 'Grach' (Rook), NATO callsigned 'Frogfoot', is a dedicated +description: + The Su-25 'Grach' (Rook), NATO callsigned 'Frogfoot', is a dedicated strike attack aircraft designed for the close air support and anti-tank roles. The Su-25 has seen combat in several conflicts during its more than 30 years in service. The Su-25 combines excellent pilot protection and high speed compared to most dedicated @@ -10,5 +11,6 @@ manufacturer: Sukhoi origin: USSR/Russia price: 18 role: Close Air Support/Attack +max_range: 200 variants: Su-25T Frogfoot: {} diff --git a/resources/units/aircraft/Su-27.yaml b/resources/units/aircraft/Su-27.yaml index cadef3cc..da9f3c87 100644 --- a/resources/units/aircraft/Su-27.yaml +++ b/resources/units/aircraft/Su-27.yaml @@ -1,4 +1,5 @@ -description: The Su-27, NATO codename Flanker, is one of the pillars of modern-day +description: + The Su-27, NATO codename Flanker, is one of the pillars of modern-day Russian combat aviation. Built to counter the American F-15 Eagle, the Flanker is a twin-engine, supersonic, highly manoeuvrable air superiority fighter. The Flanker is equally capable of engaging targets well beyond visual range as it is in a dogfight @@ -13,5 +14,6 @@ manufacturer: Sukhoi origin: USSR/Russia price: 18 role: Air-Superiority Fighter +max_range: 300 variants: Su-27 Flanker-B: {} diff --git a/resources/units/aircraft/Su-30.yaml b/resources/units/aircraft/Su-30.yaml index ad93e33a..d7922a31 100644 --- a/resources/units/aircraft/Su-30.yaml +++ b/resources/units/aircraft/Su-30.yaml @@ -1,4 +1,5 @@ -description: "The Sukhoi Su-30 (Russian: \u0421\u0443\u0445\u043E\u0439 \u0421\u0443\ +description: + "The Sukhoi Su-30 (Russian: \u0421\u0443\u0445\u043E\u0439 \u0421\u0443\ -30; NATO reporting name: Flanker-C/G/H) is a twin-engine, two-seat supermaneuverable\ \ fighter aircraft developed in the Soviet Union by Russia's Sukhoi Aviation Corporation.\ \ It is a multirole fighter for all-weather, air-to-air and air-to-surface deep\ @@ -8,6 +9,7 @@ manufacturer: Sukhoi origin: USSR/Russia price: 23 role: Multirole Fighter +max_range: 300 variants: Su-30 Flanker-C: {} Su-30MKK Flanker-G: diff --git a/resources/units/aircraft/Su-33.yaml b/resources/units/aircraft/Su-33.yaml index 63a5f29f..1b6568da 100644 --- a/resources/units/aircraft/Su-33.yaml +++ b/resources/units/aircraft/Su-33.yaml @@ -1,5 +1,6 @@ carrier_capable: true -description: 'The Su-33 has been the backbone of Russian aircraft carrier aviation +description: + 'The Su-33 has been the backbone of Russian aircraft carrier aviation since the late 1990s and is an all-weather fighter capable of engaging both air and surface targets. Based on the powerful Su-27 "Flanker", the Su-33 is a navalized version suited for operations aboard the Admiral Kuznetsov aircraft carrier. Changes @@ -20,6 +21,7 @@ manufacturer: Sukhoi origin: USSR/Russia price: 22 role: Carrier-based Multirole Fighter +max_range: 300 variants: J-15 Flanker X-2: introduced: 2013 diff --git a/resources/units/aircraft/Su-34.yaml b/resources/units/aircraft/Su-34.yaml index 37d5a4e1..bc971598 100644 --- a/resources/units/aircraft/Su-34.yaml +++ b/resources/units/aircraft/Su-34.yaml @@ -1,4 +1,5 @@ -description: "The Sukhoi Su-34 (Russian: \u0421\u0443\u0445\u043E\u0439 \u0421\u0443\ +description: + "The Sukhoi Su-34 (Russian: \u0421\u0443\u0445\u043E\u0439 \u0421\u0443\ -34; NATO reporting name: Fullback) is a Soviet-origin Russian twin-engine, twin-seat,\ \ all-weather supersonic medium-range fighter-bomber/strike aircraft. It first flew\ \ in 1990, intended for the Soviet Air Forces, and it entered service in 2014 with\ @@ -16,5 +17,6 @@ manufacturer: Sukhoi origin: USSR/Russia price: 26 role: Fighter-Bomber/Strike Fighter +max_range: 300 variants: Su-34 Fullback: {} diff --git a/resources/units/aircraft/Tu-160.yaml b/resources/units/aircraft/Tu-160.yaml index fb5b877a..fcd58b36 100644 --- a/resources/units/aircraft/Tu-160.yaml +++ b/resources/units/aircraft/Tu-160.yaml @@ -1,4 +1,5 @@ -description: "The Tupolev Tu-160 (Russian: \u0422\u0443\u043F\u043E\u043B\u0435\u0432\ +description: + "The Tupolev Tu-160 (Russian: \u0422\u0443\u043F\u043E\u043B\u0435\u0432\ \ \u0422\u0443-160 \u0411\u0435\u043B\u044B\u0439 \u043B\u0435\u0431\u0435\u0434\ \u044C, romanized: Belyj Lebe\u010F, lit.\u2009'White Swan'; NATO reporting name:\ \ Blackjack) is a supersonic, variable-sweep wing heavy strategic bomber designed\ @@ -12,5 +13,6 @@ manufacturer: Tupolev origin: USSR/Russia price: 45 role: Supersonic Strategic Bomber +max_range: 2000 variants: Tu-160 Blackjack: {}