diff --git a/changelog.md b/changelog.md index 8cb857c9..e1f3b9e0 100644 --- a/changelog.md +++ b/changelog.md @@ -4,10 +4,10 @@ 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]** (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 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. @@ -15,9 +15,10 @@ Saves from 4.x are not compatible with 5.0. * **[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). * **[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 or rename squadrons. +* **[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 diff --git a/game/campaignloader/__init__.py b/game/campaignloader/__init__.py index ed7204dc..937a39b2 100644 --- a/game/campaignloader/__init__.py +++ b/game/campaignloader/__init__.py @@ -1 +1,2 @@ from .campaign import Campaign +from .campaignairwingconfig import CampaignAirWingConfig, SquadronConfig diff --git a/game/campaignloader/campaign.py b/game/campaignloader/campaign.py index ab13e61c..29cc45b7 100644 --- a/game/campaignloader/campaign.py +++ b/game/campaignloader/campaign.py @@ -10,7 +10,6 @@ from typing import Tuple, Dict, Any from packaging.version import Version import yaml -from game.campaignloader.mizcampaignloader import MizCampaignLoader from game.profiling import logged_duration from game.theater import ( ConflictTheater, @@ -23,6 +22,8 @@ from game.theater import ( MarianaIslandsTheater, ) from game.version import CAMPAIGN_FORMAT_VERSION +from .campaignairwingconfig import CampaignAirWingConfig +from .mizcampaignloader import MizCampaignLoader PERF_FRIENDLY = 0 @@ -103,6 +104,14 @@ class Campaign: 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. 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 index ab99e265..d217250f 100644 --- a/game/campaignloader/mizcampaignloader.py +++ b/game/campaignloader/mizcampaignloader.py @@ -255,16 +255,16 @@ class MizCampaignLoader: 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) + 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): - # TODO: Name the LHA.db - control_point = Lha("lha", ship.position, next(self.control_point_id)) + 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 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..9804d553 100644 --- a/game/coalition.py +++ b/game/coalition.py @@ -5,6 +5,8 @@ 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 @@ -12,7 +14,7 @@ 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 +23,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 +41,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 @@ -100,14 +101,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 +114,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 diff --git a/game/commander/aircraftallocator.py b/game/commander/aircraftallocator.py index a50dbd22..b8bfa812 100644 --- a/game/commander/aircraftallocator.py +++ b/game/commander/aircraftallocator.py @@ -2,7 +2,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.squadron import Squadron +from game.squadrons.airwing import AirWing from game.theater import ControlPoint, MissionTarget from game.utils import meters from gen.flights.ai_flight_planner_db import aircraft_for_task @@ -67,7 +68,7 @@ class AircraftAllocator: # 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( diff --git a/game/commander/packagebuilder.py b/game/commander/packagebuilder.py index da96a8e2..f39c0e07 100644 --- a/game/commander/packagebuilder.py +++ b/game/commander/packagebuilder.py @@ -3,10 +3,10 @@ from typing import Optional 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 gen.ato import Package from game.commander.aircraftallocator import AircraftAllocator from gen.flights.closestairfields import ClosestAirfields from gen.flights.flight import Flight diff --git a/game/commander/packagefulfiller.py b/game/commander/packagefulfiller.py index 83dbcf76..87842a9e 100644 --- a/game/commander/packagefulfiller.py +++ b/game/commander/packagefulfiller.py @@ -13,8 +13,8 @@ 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 diff --git a/game/commander/tasks/packageplanningtask.py b/game/commander/tasks/packageplanningtask.py index cf75eb1b..65a7ffd8 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: 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 b54d9bc0..618f08d8 100644 --- a/game/dcs/aircrafttype.py +++ b/game/dcs/aircrafttype.py @@ -41,8 +41,8 @@ from game.utils import ( 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) diff --git a/game/event/event.py b/game/event/event.py index 757b9be1..caf68a38 100644 --- a/game/event/event.py +++ b/game/event/event.py @@ -11,7 +11,7 @@ from game.debriefing import AirLosses, 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 diff --git a/game/game.py b/game/game.py index 1b9d1c7f..8401b6ad 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.mapping import Point from dcs.task import CAP, CAS, PinpointStrike @@ -21,6 +23,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 @@ -28,10 +31,8 @@ from .event.frontlineattack import FrontlineAttackEvent from .factions.faction import Faction from .infos.information import Information from .navmesh import NavMesh -from .procurement import AircraftProcurementRequest 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 @@ -39,6 +40,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 = { @@ -85,6 +89,7 @@ class Game: player_faction: Faction, enemy_faction: Faction, theater: ConflictTheater, + air_wing_config: CampaignAirWingConfig, start_date: datetime, settings: Settings, player_budget: float, @@ -119,6 +124,9 @@ class Game: self.blue.set_opponent(self.red) self.red.set_opponent(self.blue) + self.blue.configure_default_air_wing(air_wing_config) + self.red.configure_default_air_wing(air_wing_config) + self.aircraft_inventory = GlobalAircraftInventory(self.theater.controlpoints) self.on_load(game_still_initializing=True) diff --git a/game/inventory.py b/game/inventory.py index f7f0dbe1..e4d40789 100644 --- a/game/inventory.py +++ b/game/inventory.py @@ -5,10 +5,10 @@ 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 + from gen.flights.flight import Flight class ControlPointAircraftInventory: diff --git a/game/operation/operation.py b/game/operation/operation.py index 87b440d6..59952fdf 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, 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 diff --git a/game/procurement.py b/game/procurement.py index 950e19d0..0690cf39 100644 --- a/game/procurement.py +++ b/game/procurement.py @@ -2,7 +2,7 @@ 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 @@ -10,6 +10,7 @@ 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 @@ -209,32 +210,38 @@ class ProcurementAi: return GroundUnitClass.Tank return worst_balanced + @staticmethod + def _compatible_squadron_at( + aircraft: AircraftType, airbase: ControlPoint, task: FlightType, count: int + ) -> Optional[Squadron]: + for squadron in airbase.squadrons: + if squadron.aircraft != aircraft: + continue + if not squadron.can_auto_assign(task): + continue + if not squadron.can_provide_pilots(count): + continue + return squadron + return None + 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): + + squadron = self._compatible_squadron_at( + unit, airbase, request.task_capability, request.number + ) + if squadron is None: 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. 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/squadrons.py b/game/squadrons.py deleted file mode 100644 index 53bf6989..00000000 --- a/game/squadrons.py +++ /dev/null @@ -1,507 +0,0 @@ -from __future__ import annotations - -import dataclasses -import itertools -import logging -import random -from collections import defaultdict, Iterable -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 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 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..a710f918 --- /dev/null +++ b/game/squadrons/airwing.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import itertools +from collections import defaultdict +from typing import Sequence, Iterator, TYPE_CHECKING + +from game.dcs.aircrafttype import AircraftType +from gen.flights.flight import FlightType +from .squadron import Squadron +from ..theater import ControlPoint + +if TYPE_CHECKING: + from game import Game + + +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 + + 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, 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() + + @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..369514c5 --- /dev/null +++ b/game/squadrons/squadron.py @@ -0,0 +1,262 @@ +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 + + 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 + + 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(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 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] + + @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/conflicttheater.py b/game/theater/conflicttheater.py index 4ac622cf..7cf3fcd2 100644 --- a/game/theater/conflicttheater.py +++ b/game/theater/conflicttheater.py @@ -241,6 +241,12 @@ class ConflictTheater: return i raise KeyError(f"Cannot find ControlPoint with ID {id}") + 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: raise NotImplementedError diff --git a/game/theater/controlpoint.py b/game/theater/controlpoint.py index f0c5286b..2960bf77 100644 --- a/game/theater/controlpoint.py +++ b/game/theater/controlpoint.py @@ -54,6 +54,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 @@ -322,6 +323,8 @@ class ControlPoint(MissionTarget, ABC): self.target_position: Optional[Point] = None + self.squadrons: list[Squadron] = [] + def __repr__(self) -> str: return f"<{self.__class__}: {self.name}>" diff --git a/game/theater/start_generator.py b/game/theater/start_generator.py index 4ad78ec3..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,6 +49,7 @@ from . import ( Fob, OffMapSpawn, ) +from ..campaignloader.campaignairwingconfig import CampaignAirWingConfig from ..profiling import logged_duration from ..settings import Settings @@ -85,6 +86,7 @@ class GameGenerator: player: Faction, enemy: Faction, theater: ConflictTheater, + air_wing_config: CampaignAirWingConfig, settings: Settings, generator_settings: GeneratorSettings, mod_settings: ModSettings, @@ -92,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 @@ -105,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 643bcacb..9146c5b8 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, @@ -68,6 +67,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: @@ -318,7 +318,7 @@ class AirliftPlanner: 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 + unit_type, FlightType.TRANSPORT, cp ) for squadron in squadrons: if self.compatible_with_mission(unit_type, cp): diff --git a/game/version.py b/game/version.py index d89d875d..6d7afc30 100644 --- a/game/version.py +++ b/game/version.py @@ -110,4 +110,9 @@ VERSION = _build_version_string() #: Version 8.0 #: * DCS 2.7.4.9632 changed scenery target IDs. Any mission using map buildings as #: strike targets must check and potentially recreate all those objectives. -CAMPAIGN_FORMAT_VERSION = (8, 0) +#: +#: 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 d42e52f2..3564d1ec 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 WARM_START_HELI_ALT = meters(500) WARM_START_ALTITUDE = meters(3000) 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/flight.py b/gen/flights/flight.py index 9bb0c9bc..6b60ef01 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. @@ -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) 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/qt_ui/main.py b/qt_ui/main.py index e7aad522..c4d6e4e0 100644 --- a/qt_ui/main.py +++ b/qt_ui/main.py @@ -233,10 +233,12 @@ def create_game( # way. inject_custom_payloads(Path(persistency.base_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..4ea93965 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 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 ac6000f6..82638f3a 100644 --- a/qt_ui/windows/AirWingConfigurationDialog.py +++ b/qt_ui/windows/AirWingConfigurationDialog.py @@ -28,7 +28,7 @@ from PySide2.QtWidgets import ( 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 gen.flights.flight import FlightType from qt_ui.models import AirWingModel, SquadronModel from qt_ui.uiconstants import AIRCRAFT_ICONS diff --git a/qt_ui/windows/AirWingDialog.py b/qt_ui/windows/AirWingDialog.py index df0cf81c..f7da67da 100644 --- a/qt_ui/windows/AirWingDialog.py +++ b/qt_ui/windows/AirWingDialog.py @@ -34,13 +34,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) 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/airfield/QAircraftRecruitmentMenu.py b/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py index 88ddbffd..803fb21a 100644 --- a/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py +++ b/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py @@ -45,22 +45,10 @@ 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 unit_type not in helicopter_map.values() - ): - continue - unit_types.add(unit_type) + for squadron in cp.squadrons: + unit_types.add(squadron.aircraft) - sorted_units = sorted( - unit_types, - key=lambda u: u.name, - ) + 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) stretch = QVBoxLayout() diff --git a/qt_ui/windows/mission/flight/QFlightCreator.py b/qt_ui/windows/mission/flight/QFlightCreator.py index 3c0a1e74..eeeb54de 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 diff --git a/qt_ui/windows/mission/flight/SquadronSelector.py b/qt_ui/windows/mission/flight/SquadronSelector.py index e9b7ae7f..a931f9aa 100644 --- a/qt_ui/windows/mission/flight/SquadronSelector.py +++ b/qt_ui/windows/mission/flight/SquadronSelector.py @@ -4,7 +4,7 @@ from typing import Type, Optional from PySide2.QtWidgets import QComboBox from dcs.unittype import FlyingType -from game.squadrons import AirWing +from game.squadrons.airwing import AirWing from gen.flights.flight import FlightType diff --git a/qt_ui/windows/mission/flight/settings/QFlightSlotEditor.py b/qt_ui/windows/mission/flight/settings/QFlightSlotEditor.py index 3db6ab2d..3545ff1e 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 diff --git a/qt_ui/windows/newgame/QNewGameWizard.py b/qt_ui/windows/newgame/QNewGameWizard.py index 0bbd0715..0b1f4539 100644 --- a/qt_ui/windows/newgame/QNewGameWizard.py +++ b/qt_ui/windows/newgame/QNewGameWizard.py @@ -113,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,