diff --git a/game/data/weapons.py b/game/data/weapons.py index 7aa21beb..668cc028 100644 --- a/game/data/weapons.py +++ b/game/data/weapons.py @@ -5,12 +5,12 @@ import inspect import logging from collections import defaultdict from dataclasses import dataclass -from typing import Dict, Iterator, Optional, Set, Tuple, Type, Union, cast +from typing import Dict, Iterator, Optional, Set, Tuple, Union, cast from dcs.unitgroup import FlyingGroup -from dcs.unittype import FlyingType from dcs.weapons_data import Weapons, weapon_ids +from game.dcs.aircrafttype import AircraftType PydcsWeapon = Dict[str, Union[int, str]] PydcsWeaponAssignment = Tuple[int, PydcsWeapon] @@ -97,12 +97,12 @@ class Pylon: yield weapon @classmethod - def for_aircraft(cls, aircraft: Type[FlyingType], number: int) -> Pylon: + def for_aircraft(cls, aircraft: AircraftType, number: int) -> Pylon: # In pydcs these are all arbitrary inner classes of the aircraft type. # The only way to identify them is by their name. pylons = [ v - for v in aircraft.__dict__.values() + for v in aircraft.dcs_unit_type.__dict__.values() if inspect.isclass(v) and v.__name__.startswith("Pylon") ] @@ -121,8 +121,8 @@ class Pylon: return cls(number, allowed) @classmethod - def iter_pylons(cls, aircraft: Type[FlyingType]) -> Iterator[Pylon]: - for pylon in sorted(list(aircraft.pylons)): + def iter_pylons(cls, aircraft: AircraftType) -> Iterator[Pylon]: + for pylon in sorted(list(aircraft.dcs_unit_type.pylons)): yield cls.for_aircraft(aircraft, pylon) diff --git a/game/db.py b/game/db.py index b87bdac4..d403f04e 100644 --- a/game/db.py +++ b/game/db.py @@ -133,7 +133,7 @@ from dcs.ships import ( from dcs.terrain.terrain import Airport from dcs.unit import Ship, Unit, Vehicle from dcs.unitgroup import ShipGroup, StaticGroup -from dcs.unittype import FlyingType, UnitType, VehicleType +from dcs.unittype import UnitType, VehicleType from dcs.vehicles import ( AirDefence, Armor, @@ -803,44 +803,6 @@ REWARDS = { "derrick": 8, } -CARRIER_CAPABLE = [ - FA_18C_hornet, - F_14A_135_GR, - F_14B, - AV8BNA, - Su_33, - A_4E_C, - S_3B, - S_3B_Tanker, - E_2C, - UH_1H, - Mi_8MT, - Ka_50, - AH_1W, - OH_58D, - UH_60A, - SH_60B, - SA342L, - SA342M, - SA342Minigun, - SA342Mistral, -] - -LHA_CAPABLE = [ - AV8BNA, - UH_1H, - Mi_8MT, - Ka_50, - AH_1W, - OH_58D, - UH_60A, - SH_60B, - SA342L, - SA342M, - SA342Minigun, - SA342Mistral, -] - """ ---------- END OF CONFIGURATION SECTION """ @@ -938,7 +900,9 @@ def unit_type_name_2(unit_type) -> str: return unit_type.name and unit_type.name or unit_type.id -def unit_get_expanded_info(country_name: str, unit_type, request_type: str) -> str: +def unit_get_expanded_info( + country_name: str, unit_type: Type[UnitType], request_type: str +) -> str: original_name = unit_type.name and unit_type.name or unit_type.id default_value = None faction_value = None @@ -980,13 +944,6 @@ def unit_type_from_name(name: str) -> Optional[Type[UnitType]]: return None -def flying_type_from_name(name: str) -> Optional[Type[FlyingType]]: - unit_type = plane_map.get(name) - if unit_type is not None: - return unit_type - return helicopter_map.get(name) - - def unit_type_of(unit: Unit) -> UnitType: if isinstance(unit, Vehicle): return vehicle_map[unit.type] @@ -1013,39 +970,3 @@ F_16C_50.Liveries = DefaultLiveries P_51D_30_NA.Liveries = DefaultLiveries Ju_88A4.Liveries = DefaultLiveries B_17G.Liveries = DefaultLiveries - -# List of airframes that rely on their gun as a primary weapon. We confiscate bullets -# from most AI air-to-ground missions since they aren't smart enough to RTB when they're -# out of everything other than bullets (DCS does not have an all-but-gun winchester -# option) and we don't want to be attacking fully functional Tors with a Vulcan. -# -# These airframes are the exceptions. They probably should be using their gun regardless -# of the mission type. -GUN_RELIANT_AIRFRAMES: List[Type[FlyingType]] = [ - AH_1W, - AH_64A, - AH_64D, - A_10A, - A_10C, - A_10C_2, - A_20G, - Bf_109K_4, - FW_190A8, - FW_190D9, - F_86F_Sabre, - Ju_88A4, - Ka_50, - MiG_15bis, - MiG_19P, - Mi_24V, - Mi_28N, - P_47D_30, - P_47D_30bl1, - P_47D_40, - P_51D, - P_51D_30_NA, - SpitfireLFMkIX, - SpitfireLFMkIXCW, - Su_25, - Su_25T, -] diff --git a/game/dcs/aircrafttype.py b/game/dcs/aircrafttype.py new file mode 100644 index 00000000..5ca4f255 --- /dev/null +++ b/game/dcs/aircrafttype.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +import logging +from collections import defaultdict +from dataclasses import dataclass +from functools import cached_property +from pathlib import Path +from typing import ClassVar, Type, Iterator, TYPE_CHECKING, Optional, Any + +import yaml +from dcs.helicopters import helicopter_map +from dcs.planes import plane_map +from dcs.unittype import FlyingType + +from game.radio.channels import ( + ChannelNamer, + RadioChannelAllocator, + CommonRadioChannelAllocator, +) +from game.utils import Speed, kph + +if TYPE_CHECKING: + from gen.aircraft import FlightData + from gen import AirSupport, RadioFrequency, RadioRegistry + from gen.radios import Radio + + +@dataclass(frozen=True) +class RadioConfig: + inter_flight: Optional[Radio] + intra_flight: Optional[Radio] + channel_allocator: Optional[RadioChannelAllocator] + channel_namer: Type[ChannelNamer] + + @classmethod + def from_data(cls, data: dict[str, Any]) -> RadioConfig: + return RadioConfig( + cls.make_radio(data.get("inter_flight", None)), + cls.make_radio(data.get("intra_flight", None)), + cls.make_allocator(data.get("channels", {})), + cls.make_namer(data.get("channels", {})), + ) + + @classmethod + def make_radio(cls, name: Optional[str]) -> Optional[Radio]: + from gen.radios import get_radio + + if name is None: + return None + return get_radio(name) + + @classmethod + def make_allocator(cls, data: dict[str, Any]) -> Optional[RadioChannelAllocator]: + try: + alloc_type = data["type"] + except KeyError: + return None + return {"common": CommonRadioChannelAllocator}[alloc_type].from_cfg(data) + + @classmethod + def make_namer(cls, config: dict[str, Any]) -> Type[ChannelNamer]: + return {"default": ChannelNamer}[config.get("namer", "default")] + + +@dataclass(frozen=True) +class AircraftType: + dcs_unit_type: Type[FlyingType] + name: str + description: str + price: int + carrier_capable: bool + lha_capable: bool + always_keeps_gun: bool + intra_flight_radio: Optional[Radio] + channel_allocator: Optional[RadioChannelAllocator] + channel_namer: Type[ChannelNamer] + + _by_name: ClassVar[dict[str, AircraftType]] = {} + _by_unit_type: ClassVar[dict[Type[FlyingType], list[AircraftType]]] = defaultdict( + list + ) + _loaded: ClassVar[bool] = False + + def __str__(self) -> str: + return self.name + + @property + def dcs_id(self) -> str: + return self.dcs_unit_type.id + + @property + def flyable(self) -> bool: + return self.dcs_unit_type.flyable + + @cached_property + def max_speed(self) -> Speed: + return kph(self.dcs_unit_type.max_speed) + + def alloc_flight_radio(self, radio_registry: RadioRegistry) -> RadioFrequency: + from gen.radios import ChannelInUseError, MHz + + if self.intra_flight_radio is not None: + return radio_registry.alloc_for_radio(self.intra_flight_radio) + + freq = MHz(self.dcs_unit_type.radio_frequency) + try: + radio_registry.reserve(freq) + except ChannelInUseError: + pass + return freq + + def assign_channels_for_flight( + self, flight: FlightData, air_support: AirSupport + ) -> None: + if self.channel_allocator is not None: + self.channel_allocator.assign_channels_for_flight(flight, air_support) + + def channel_name(self, radio_id: int, channel_id: int) -> str: + return self.channel_namer.channel_name(radio_id, channel_id) + + @classmethod + def register(cls, aircraft_type: AircraftType) -> None: + cls._by_name[aircraft_type.name] = aircraft_type + cls._by_unit_type[aircraft_type.dcs_unit_type].append(aircraft_type) + + @classmethod + def named(cls, name: str) -> AircraftType: + if not cls._loaded: + cls._load_all() + return cls._by_name[name] + + @classmethod + def for_dcs_type(cls, dcs_unit_type: Type[FlyingType]) -> Iterator[AircraftType]: + yield from cls._by_unit_type[dcs_unit_type] + + @staticmethod + def _each_unit_type() -> Iterator[Type[FlyingType]]: + yield from helicopter_map.values() + yield from plane_map.values() + + @classmethod + def _load_all(cls) -> None: + for unit_type in cls._each_unit_type(): + for data in cls._each_variant_of(unit_type): + cls.register(data) + cls._loaded = True + + @classmethod + def _each_variant_of(cls, aircraft: Type[FlyingType]) -> Iterator[AircraftType]: + data_path = Path("resources/units/aircraft") / f"{aircraft.id}.yaml" + if not data_path.exists(): + logging.warning(f"No data for {aircraft.id}; it will not be available") + return + + with data_path.open() as data_file: + data = yaml.safe_load(data_file) + + try: + price = data["price"] + except KeyError as ex: + raise KeyError(f"Missing required price field: {data_path}") from ex + + radio_config = RadioConfig.from_data(data.get("radios", {})) + + for variant in data.get("variants", [aircraft.id]): + yield AircraftType( + dcs_unit_type=aircraft, + name=variant, + description=data.get("description", "No data."), + price=price, + carrier_capable=data.get("carrier_capable", False), + lha_capable=data.get("lha_capable", False), + always_keeps_gun=data.get("always_keeps_gun", False), + intra_flight_radio=radio_config.intra_flight, + channel_allocator=radio_config.channel_allocator, + channel_namer=radio_config.channel_namer, + ) diff --git a/game/debriefing.py b/game/debriefing.py index 88c9f8ae..e2166a74 100644 --- a/game/debriefing.py +++ b/game/debriefing.py @@ -18,9 +18,10 @@ from typing import ( TYPE_CHECKING, ) -from dcs.unittype import FlyingType, UnitType +from dcs.unittype import UnitType from game import db +from game.dcs.aircrafttype import AircraftType from game.theater import Airfield, ControlPoint from game.transfers import CargoShip from game.unitmap import ( @@ -49,8 +50,8 @@ class AirLosses: def losses(self) -> Iterator[FlyingUnit]: return itertools.chain(self.player, self.enemy) - def by_type(self, player: bool) -> Dict[Type[FlyingType], int]: - losses_by_type: Dict[Type[FlyingType], int] = defaultdict(int) + def by_type(self, player: bool) -> Dict[AircraftType, int]: + losses_by_type: Dict[AircraftType, int] = defaultdict(int) losses = self.player if player else self.enemy for loss in losses: losses_by_type[loss.flight.unit_type] += 1 diff --git a/game/factions/faction.py b/game/factions/faction.py index cdb75ed0..8a09807c 100644 --- a/game/factions/faction.py +++ b/game/factions/faction.py @@ -1,5 +1,4 @@ from __future__ import annotations -from game.data.groundunitclass import GroundUnitClass import logging from dataclasses import dataclass, field @@ -23,7 +22,9 @@ from game.data.doctrine import ( COLDWAR_DOCTRINE, WWII_DOCTRINE, ) -from pydcs_extensions.mod_units import MODDED_VEHICLES, MODDED_AIRPLANES +from game.data.groundunitclass import GroundUnitClass +from game.dcs.aircrafttype import AircraftType +from pydcs_extensions.mod_units import MODDED_VEHICLES @dataclass @@ -45,13 +46,13 @@ class Faction: description: str = field(default="") # Available aircraft - aircrafts: List[Type[FlyingType]] = field(default_factory=list) + aircrafts: List[AircraftType] = field(default_factory=list) # Available awacs aircraft - awacs: List[Type[FlyingType]] = field(default_factory=list) + awacs: List[AircraftType] = field(default_factory=list) # Available tanker aircraft - tankers: List[Type[FlyingType]] = field(default_factory=list) + tankers: List[AircraftType] = field(default_factory=list) # Available frontline units frontline_units: List[Type[VehicleType]] = field(default_factory=list) @@ -114,7 +115,7 @@ class Faction: has_jtac: bool = field(default=False) # Unit to use as JTAC for this faction - jtac_unit: Optional[Type[FlyingType]] = field(default=None) + jtac_unit: Optional[AircraftType] = field(default=None) # doctrine doctrine: Doctrine = field(default=MODERN_DOCTRINE) @@ -123,7 +124,7 @@ class Faction: building_set: List[str] = field(default_factory=list) # List of default livery overrides - liveries_overrides: Dict[Type[UnitType], List[str]] = field(default_factory=dict) + liveries_overrides: Dict[AircraftType, List[str]] = field(default_factory=dict) #: Set to True if the faction should force the "Unrestricted satnav" option #: for the mission. This option enables GPS for capable aircraft regardless @@ -163,9 +164,9 @@ class Faction: faction.authors = json.get("authors", "") faction.description = json.get("description", "") - faction.aircrafts = load_all_aircraft(json.get("aircrafts", [])) - faction.awacs = load_all_aircraft(json.get("awacs", [])) - faction.tankers = load_all_aircraft(json.get("tankers", [])) + faction.aircrafts = [AircraftType.named(n) for n in json.get("aircrafts", [])] + faction.awacs = [AircraftType.named(n) for n in json.get("awacs", [])] + faction.tankers = [AircraftType.named(n) for n in json.get("tankers", [])] faction.aircrafts = list( set(faction.aircrafts + faction.awacs + faction.tankers) @@ -198,7 +199,7 @@ class Faction: faction.has_jtac = json.get("has_jtac", False) jtac_name = json.get("jtac_unit", None) if jtac_name is not None: - faction.jtac_unit = load_aircraft(jtac_name) + faction.jtac_unit = AircraftType.named(jtac_name) else: faction.jtac_unit = None faction.navy_group_count = int(json.get("navy_group_count", 1)) @@ -232,27 +233,14 @@ class Faction: # Load liveries override faction.liveries_overrides = {} liveries_overrides = json.get("liveries_overrides", {}) - for k, v in liveries_overrides.items(): - k = load_aircraft(k) - if k is not None: - faction.liveries_overrides[k] = [s.lower() for s in v] + for name, livery in liveries_overrides.items(): + aircraft = AircraftType.named(name) + faction.liveries_overrides[aircraft] = [s.lower() for s in livery] faction.unrestricted_satnav = json.get("unrestricted_satnav", False) return faction - @property - def all_units(self) -> List[Type[UnitType]]: - return ( - self.infantry_units - + self.aircrafts - + self.awacs - + self.artillery_units - + self.frontline_units - + self.tankers - + self.logistics_units - ) - @property def ground_units(self) -> Iterator[Type[VehicleType]]: yield from self.artillery_units @@ -283,22 +271,6 @@ def unit_loader(unit: str, class_repository: List[Any]) -> Optional[Type[UnitTyp return None -def load_aircraft(name: str) -> Optional[Type[FlyingType]]: - return cast( - Optional[FlyingType], - unit_loader(name, [dcs.planes, dcs.helicopters, MODDED_AIRPLANES]), - ) - - -def load_all_aircraft(data) -> List[Type[FlyingType]]: - items = [] - for name in data: - item = load_aircraft(name) - if item is not None: - items.append(item) - return items - - def load_vehicle(name: str) -> Optional[Type[VehicleType]]: return cast( Optional[FlyingType], diff --git a/game/inventory.py b/game/inventory.py index 651b80a8..4014c05c 100644 --- a/game/inventory.py +++ b/game/inventory.py @@ -6,6 +6,7 @@ from typing import Dict, Iterable, Iterator, Set, Tuple, TYPE_CHECKING, Type from dcs.unittype import FlyingType +from game.dcs.aircrafttype import AircraftType from gen.flights.flight import Flight if TYPE_CHECKING: @@ -17,9 +18,9 @@ class ControlPointAircraftInventory: def __init__(self, control_point: ControlPoint) -> None: self.control_point = control_point - self.inventory: Dict[Type[FlyingType], int] = defaultdict(int) + self.inventory: Dict[AircraftType, int] = defaultdict(int) - def add_aircraft(self, aircraft: Type[FlyingType], count: int) -> None: + def add_aircraft(self, aircraft: AircraftType, count: int) -> None: """Adds aircraft to the inventory. Args: @@ -28,7 +29,7 @@ class ControlPointAircraftInventory: """ self.inventory[aircraft] += count - def remove_aircraft(self, aircraft: Type[FlyingType], count: int) -> None: + def remove_aircraft(self, aircraft: AircraftType, count: int) -> None: """Removes aircraft from the inventory. Args: @@ -42,12 +43,12 @@ class ControlPointAircraftInventory: available = self.inventory[aircraft] if available < count: raise ValueError( - f"Cannot remove {count} {aircraft.id} from " + f"Cannot remove {count} {aircraft} from " f"{self.control_point.name}. Only have {available}." ) self.inventory[aircraft] -= count - def available(self, aircraft: Type[FlyingType]) -> int: + def available(self, aircraft: AircraftType) -> int: """Returns the number of available aircraft of the given type. Args: @@ -59,14 +60,14 @@ class ControlPointAircraftInventory: return 0 @property - def types_available(self) -> Iterator[Type[FlyingType]]: + 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[Type[FlyingType], int]]: + 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: @@ -107,9 +108,9 @@ class GlobalAircraftInventory: return self.inventories[control_point] @property - def available_types_for_player(self) -> Iterator[Type[FlyingType]]: + def available_types_for_player(self) -> Iterator[AircraftType]: """Iterates over all aircraft types available to the player.""" - seen: Set[Type[FlyingType]] = set() + seen: Set[AircraftType] = set() for control_point, inventory in self.inventories.items(): if control_point.captured: for aircraft in inventory.types_available: diff --git a/game/operation/operation.py b/game/operation/operation.py index 2f683f78..5a4f7c3b 100644 --- a/game/operation/operation.py +++ b/game/operation/operation.py @@ -17,7 +17,7 @@ from dcs.triggers import TriggerStart from game.plugins import LuaPluginManager from game.theater.theatergroundobject import TheaterGroundObject from gen import Conflict, FlightType, VisualGenerator -from gen.aircraft import AIRCRAFT_DATA, AircraftConflictGenerator, FlightData +from gen.aircraft import AircraftConflictGenerator, FlightData from gen.airfields import AIRFIELD_DATA from gen.airsupportgen import AirSupport, AirSupportConflictGenerator from gen.armor import GroundConflictGenerator, JtacInfo @@ -215,23 +215,7 @@ class Operation: for flight in flights: if not flight.client_units: continue - cls.assign_channels_to_flight(flight, air_support) - - @staticmethod - def assign_channels_to_flight(flight: FlightData, air_support: AirSupport) -> None: - """Assigns preset radio channels for a client flight.""" - airframe = flight.aircraft_type - - try: - aircraft_data = AIRCRAFT_DATA[airframe.id] - except KeyError: - logging.warning(f"No aircraft data for {airframe.id}") - return - - if aircraft_data.channel_allocator is not None: - aircraft_data.channel_allocator.assign_channels_for_flight( - flight, air_support - ) + flight.aircraft_type.assign_channels_for_flight(flight, air_support) @classmethod def _create_tacan_registry( diff --git a/game/procurement.py b/game/procurement.py index 021014ce..644d9c0b 100644 --- a/game/procurement.py +++ b/game/procurement.py @@ -9,6 +9,7 @@ from dcs.unittype import FlyingType, VehicleType from game import db from game.data.groundunitclass import GroundUnitClass +from game.dcs.aircrafttype import AircraftType from game.factions.faction import Faction from game.theater import ControlPoint, MissionTarget from game.utils import Distance @@ -113,7 +114,7 @@ class ProcurementAi: if available % 2 == 0: continue inventory.remove_aircraft(aircraft, 1) - total += db.PRICES[aircraft] + total += aircraft.price return total def repair_runways(self, budget: float) -> float: @@ -198,12 +199,12 @@ class ProcurementAi: airbase: ControlPoint, number: int, max_price: float, - ) -> Optional[Type[FlyingType]]: - best_choice: Optional[Type[FlyingType]] = None + ) -> Optional[AircraftType]: + best_choice: Optional[AircraftType] = None for unit in aircraft_for_task(task): if unit not in self.faction.aircrafts: continue - if db.PRICES[unit] * number > max_price: + if unit.price * number > max_price: continue if not airbase.can_operate(unit): continue @@ -224,7 +225,7 @@ class ProcurementAi: def affordable_aircraft_for( self, request: AircraftProcurementRequest, airbase: ControlPoint, budget: float - ) -> Optional[Type[FlyingType]]: + ) -> Optional[AircraftType]: return self._affordable_aircraft_for_task( request.task_capability, airbase, request.number, budget ) @@ -242,7 +243,7 @@ class ProcurementAi: # able to operate expensive aircraft. continue - budget -= db.PRICES[unit] * request.number + budget -= unit.price * request.number airbase.pending_unit_deliveries.order({unit: request.number}) return budget, True return budget, False diff --git a/game/radio/channels.py b/game/radio/channels.py new file mode 100644 index 00000000..83df8e6c --- /dev/null +++ b/game/radio/channels.py @@ -0,0 +1,298 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional, Any, TYPE_CHECKING + +if TYPE_CHECKING: + from gen import FlightData, AirSupport + + +class RadioChannelAllocator: + """Base class for radio channel allocators.""" + + def assign_channels_for_flight( + self, flight: FlightData, air_support: AirSupport + ) -> None: + """Assigns mission frequencies to preset channels for the flight.""" + raise NotImplementedError + + @classmethod + def from_cfg(cls, cfg: dict[str, Any]) -> RadioChannelAllocator: + return cls() + + @classmethod + def name(cls) -> str: + raise NotImplementedError + + +@dataclass(frozen=True) +class CommonRadioChannelAllocator(RadioChannelAllocator): + """Radio channel allocator suitable for most aircraft. + + Most of the aircraft with preset channels available have one or more radios + with 20 or more channels available (typically per-radio, but this is not the + case for the JF-17). + """ + + #: Index of the radio used for intra-flight communications. Matches the + #: index of the panel_radio field of the pydcs.dcs.planes object. + inter_flight_radio_index: Optional[int] + + #: Index of the radio used for intra-flight communications. Matches the + #: index of the panel_radio field of the pydcs.dcs.planes object. + intra_flight_radio_index: Optional[int] + + def assign_channels_for_flight( + self, flight: FlightData, air_support: AirSupport + ) -> None: + if self.intra_flight_radio_index is not None: + flight.assign_channel( + self.intra_flight_radio_index, 1, flight.intra_flight_channel + ) + + if self.inter_flight_radio_index is None: + return + + # For cases where the inter-flight and intra-flight radios share presets + # (the JF-17 only has one set of channels, even though it can use two + # channels simultaneously), start assigning inter-flight channels at 2. + radio_id = self.inter_flight_radio_index + if self.intra_flight_radio_index == radio_id: + first_channel = 2 + else: + first_channel = 1 + + last_channel = flight.num_radio_channels(radio_id) + channel_alloc = iter(range(first_channel, last_channel + 1)) + + if flight.departure.atc is not None: + flight.assign_channel(radio_id, next(channel_alloc), flight.departure.atc) + + # TODO: If there ever are multiple AWACS, limit to mission relevant. + for awacs in air_support.awacs: + flight.assign_channel(radio_id, next(channel_alloc), awacs.freq) + + if flight.arrival != flight.departure and flight.arrival.atc is not None: + flight.assign_channel(radio_id, next(channel_alloc), flight.arrival.atc) + + try: + # TODO: Skip incompatible tankers. + for tanker in air_support.tankers: + flight.assign_channel(radio_id, next(channel_alloc), tanker.freq) + + if flight.divert is not None and flight.divert.atc is not None: + flight.assign_channel(radio_id, next(channel_alloc), flight.divert.atc) + except StopIteration: + # Any remaining channels are nice-to-haves, but not necessary for + # the few aircraft with a small number of channels available. + pass + + @classmethod + def from_cfg(cls, cfg: dict[str, Any]) -> CommonRadioChannelAllocator: + return CommonRadioChannelAllocator( + inter_flight_radio_index=cfg["inter_flight_radio_index"], + intra_flight_radio_index=cfg["intra_flight_radio_index"], + ) + + @classmethod + def name(cls) -> str: + return "common" + + +@dataclass(frozen=True) +class NoOpChannelAllocator(RadioChannelAllocator): + """Channel allocator for aircraft that don't support preset channels.""" + + def assign_channels_for_flight( + self, flight: FlightData, air_support: AirSupport + ) -> None: + pass + + @classmethod + def name(cls) -> str: + return "noop" + + +@dataclass(frozen=True) +class FarmerRadioChannelAllocator(RadioChannelAllocator): + """Preset channel allocator for the MiG-19P.""" + + def assign_channels_for_flight( + self, flight: FlightData, air_support: AirSupport + ) -> None: + # The Farmer only has 6 preset channels. It also only has a VHF radio, + # and currently our ATC data and AWACS are only in the UHF band. + radio_id = 1 + flight.assign_channel(radio_id, 1, flight.intra_flight_channel) + # TODO: Assign 4-6 to VHF frequencies of departure, arrival, and divert. + # TODO: Assign 2 and 3 to AWACS if it is VHF. + + @classmethod + def name(cls) -> str: + return "farmer" + + +@dataclass(frozen=True) +class ViggenRadioChannelAllocator(RadioChannelAllocator): + """Preset channel allocator for the AJS37.""" + + def assign_channels_for_flight( + self, flight: FlightData, air_support: AirSupport + ) -> None: + # The Viggen's preset channels are handled differently from other + # aircraft. The aircraft automatically configures channels for every + # allied flight in the game (including AWACS) and for every airfield. As + # such, we don't need to allocate any of those. There are seven presets + # we can modify, however: three channels for the main radio intended for + # communication with wingmen, and four emergency channels for the backup + # radio. We'll set the first channel of the main radio to the + # intra-flight channel, and the first three emergency channels to each + # of the flight plan's airfields. The fourth emergency channel is always + # the guard channel. + radio_id = 1 + flight.assign_channel(radio_id, 1, flight.intra_flight_channel) + if flight.departure.atc is not None: + flight.assign_channel(radio_id, 4, flight.departure.atc) + if flight.arrival.atc is not None: + flight.assign_channel(radio_id, 5, flight.arrival.atc) + # TODO: Assign divert to 6 when we support divert airfields. + + @classmethod + def name(cls) -> str: + return "viggen" + + +@dataclass(frozen=True) +class SCR522RadioChannelAllocator(RadioChannelAllocator): + """Preset channel allocator for the SCR522 WW2 radios. (4 channels)""" + + def assign_channels_for_flight( + self, flight: FlightData, air_support: AirSupport + ) -> None: + radio_id = 1 + flight.assign_channel(radio_id, 1, flight.intra_flight_channel) + if flight.departure.atc is not None: + flight.assign_channel(radio_id, 2, flight.departure.atc) + if flight.arrival.atc is not None: + flight.assign_channel(radio_id, 3, flight.arrival.atc) + + # TODO : Some GCI on Channel 4 ? + + @classmethod + def name(cls) -> str: + return "SCR-522" + + +class ChannelNamer: + """Base class allowing channel name customization per-aircraft. + + Most aircraft will want to customize this behavior, but the default is + reasonable for any aircraft with numbered radios. + """ + + @staticmethod + def channel_name(radio_id: int, channel_id: int) -> str: + """Returns the name of the channel for the given radio and channel.""" + return f"COMM{radio_id} Ch {channel_id}" + + @classmethod + def name(cls) -> str: + return "default" + + +class SingleRadioChannelNamer(ChannelNamer): + """Channel namer for the aircraft with only a single radio. + + Aircraft like the MiG-19P and the MiG-21bis only have a single radio, so + it's not necessary for us to name the radio when naming the channel. + """ + + @staticmethod + def channel_name(radio_id: int, channel_id: int) -> str: + return f"Ch {channel_id}" + + @classmethod + def name(cls) -> str: + return "single" + + +class HueyChannelNamer(ChannelNamer): + """Channel namer for the UH-1H.""" + + @staticmethod + def channel_name(radio_id: int, channel_id: int) -> str: + return f"COM3 Ch {channel_id}" + + @classmethod + def name(cls) -> str: + return "huey" + + +class MirageChannelNamer(ChannelNamer): + """Channel namer for the M-2000.""" + + @staticmethod + def channel_name(radio_id: int, channel_id: int) -> str: + radio_name = ["V/UHF", "UHF"][radio_id - 1] + return f"{radio_name} Ch {channel_id}" + + @classmethod + def name(cls) -> str: + return "mirage" + + +class TomcatChannelNamer(ChannelNamer): + """Channel namer for the F-14.""" + + @staticmethod + def channel_name(radio_id: int, channel_id: int) -> str: + radio_name = ["UHF", "VHF/UHF"][radio_id - 1] + return f"{radio_name} Ch {channel_id}" + + @classmethod + def name(cls) -> str: + return "tomcat" + + +class ViggenChannelNamer(ChannelNamer): + """Channel namer for the AJS37.""" + + @staticmethod + def channel_name(radio_id: int, channel_id: int) -> str: + if channel_id >= 4: + channel_letter = "EFGH"[channel_id - 4] + return f"FR 24 {channel_letter}" + return f"FR 22 Special {channel_id}" + + @classmethod + def name(cls) -> str: + return "viggen" + + +class ViperChannelNamer(ChannelNamer): + """Channel namer for the F-16.""" + + @staticmethod + def channel_name(radio_id: int, channel_id: int) -> str: + return f"COM{radio_id} Ch {channel_id}" + + @classmethod + def name(cls) -> str: + return "viper" + + +class SCR522ChannelNamer(ChannelNamer): + """ + Channel namer for P-51 & P-47D + """ + + @staticmethod + def channel_name(radio_id: int, channel_id: int) -> str: + if channel_id > 3: + return "?" + else: + return f"Button " + "ABCD"[channel_id - 1] + + @classmethod + def name(cls) -> str: + return "SCR-522" diff --git a/game/squadrons.py b/game/squadrons.py index 4e550465..1cee891f 100644 --- a/game/squadrons.py +++ b/game/squadrons.py @@ -8,7 +8,6 @@ from dataclasses import dataclass, field from enum import unique, Enum from pathlib import Path from typing import ( - Type, Tuple, TYPE_CHECKING, Optional, @@ -17,10 +16,9 @@ from typing import ( ) import yaml -from dcs.unittype import FlyingType from faker import Faker -from game.db import flying_type_from_name +from game.dcs.aircrafttype import AircraftType from game.settings import AutoAtoBehavior if TYPE_CHECKING: @@ -79,7 +77,7 @@ class Squadron: nickname: str country: str role: str - aircraft: Type[FlyingType] + aircraft: AircraftType livery: Optional[str] mission_types: tuple[FlightType, ...] pilots: list[Pilot] @@ -196,9 +194,11 @@ class Squadron: with path.open() as squadron_file: data = yaml.safe_load(squadron_file) - unit_type = flying_type_from_name(data["aircraft"]) - if unit_type is None: - raise KeyError(f"Could not find any aircraft with the ID {unit_type}") + 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", [])]) @@ -245,8 +245,8 @@ class SquadronLoader: yield Path(persistency.base_path()) / "Liberation/Squadrons" yield Path("resources/squadrons") - def load(self) -> dict[Type[FlyingType], list[Squadron]]: - squadrons: dict[Type[FlyingType], list[Squadron]] = defaultdict(list) + def load(self) -> dict[AircraftType, list[Squadron]]: + squadrons: dict[AircraftType, list[Squadron]] = defaultdict(list) country = self.game.country_for(self.player) faction = self.game.faction_for(self.player) any_country = country.startswith("Combined Joint Task Forces ") @@ -317,7 +317,7 @@ class AirWing: ) ] - def squadrons_for(self, aircraft: Type[FlyingType]) -> Sequence[Squadron]: + def squadrons_for(self, aircraft: AircraftType) -> Sequence[Squadron]: return self.squadrons[aircraft] def squadrons_for_task(self, task: FlightType) -> Iterator[Squadron]: @@ -325,7 +325,7 @@ class AirWing: if task in squadron.mission_types: yield squadron - def squadron_for(self, aircraft: Type[FlyingType]) -> Squadron: + def squadron_for(self, aircraft: AircraftType) -> Squadron: return self.squadrons_for(aircraft)[0] def iter_squadrons(self) -> Iterator[Squadron]: diff --git a/game/theater/base.py b/game/theater/base.py index 15d14f85..2fe84981 100644 --- a/game/theater/base.py +++ b/game/theater/base.py @@ -3,9 +3,10 @@ import logging import typing from typing import Dict, Type -from dcs.unittype import FlyingType, VehicleType, UnitType +from dcs.unittype import VehicleType from game.db import PRICES +from game.dcs.aircrafttype import AircraftType BASE_MAX_STRENGTH = 1 BASE_MIN_STRENGTH = 0 @@ -13,7 +14,7 @@ BASE_MIN_STRENGTH = 0 class Base: def __init__(self): - self.aircraft: Dict[Type[FlyingType], int] = {} + self.aircraft: Dict[AircraftType, int] = {} self.armor: Dict[Type[VehicleType], int] = {} self.strength = 1 @@ -35,7 +36,7 @@ class Base: logging.exception(f"No price found for {unit_type.id}") return total - def total_units_of_type(self, unit_type) -> int: + def total_units_of_type(self, unit_type: typing.Any) -> int: return sum( [ c @@ -44,15 +45,16 @@ class Base: ] ) - def commission_units(self, units: typing.Dict[typing.Type[UnitType], int]): + def commission_units(self, units: typing.Dict[typing.Any, int]): for unit_type, unit_count in units.items(): if unit_count <= 0: continue - if issubclass(unit_type, VehicleType): - target_dict = self.armor - elif issubclass(unit_type, FlyingType): + target_dict: dict[typing.Any, int] + if isinstance(unit_type, AircraftType): target_dict = self.aircraft + elif issubclass(unit_type, VehicleType): + target_dict = self.armor else: logging.error( f"Unexpected unit type of {unit_type}: " @@ -66,21 +68,22 @@ class Base: for unit_type, count in units_lost.items(): + target_dict: dict[typing.Any, int] if unit_type in self.aircraft: - target_array = self.aircraft + target_dict = self.aircraft elif unit_type in self.armor: - target_array = self.armor + target_dict = self.armor else: print("Base didn't find event type {}".format(unit_type)) continue - if unit_type not in target_array: + if unit_type not in target_dict: print("Base didn't find event type {}".format(unit_type)) continue - target_array[unit_type] = max(target_array[unit_type] - count, 0) - if target_array[unit_type] == 0: - del target_array[unit_type] + target_dict[unit_type] = max(target_dict[unit_type] - count, 0) + if target_dict[unit_type] == 0: + del target_dict[unit_type] def affect_strength(self, amount): self.strength += amount diff --git a/game/theater/controlpoint.py b/game/theater/controlpoint.py index 83d987e7..73dc7d0a 100644 --- a/game/theater/controlpoint.py +++ b/game/theater/controlpoint.py @@ -32,7 +32,7 @@ from dcs.ships import ( ) from dcs.terrain.terrain import Airport, ParkingSlot from dcs.unit import Unit -from dcs.unittype import FlyingType, VehicleType +from dcs.unittype import VehicleType from game import db from game.point_with_heading import PointWithHeading @@ -47,6 +47,7 @@ from .theatergroundobject import ( TheaterGroundObject, ) from ..db import PRICES +from ..dcs.aircrafttype import AircraftType from ..utils import nautical_miles from ..weather import Conditions @@ -125,19 +126,19 @@ class PresetLocations: @dataclass(frozen=True) class AircraftAllocations: - present: dict[Type[FlyingType], int] - ordered: dict[Type[FlyingType], int] - transferring: dict[Type[FlyingType], int] + present: dict[AircraftType, int] + ordered: dict[AircraftType, int] + transferring: dict[AircraftType, int] @property def total_value(self) -> int: total: int = 0 for unit_type, count in self.present.items(): - total += PRICES[unit_type] * count + total += unit_type.price * count for unit_type, count in self.ordered.items(): - total += PRICES[unit_type] * count + total += unit_type.price * count for unit_type, count in self.transferring.items(): - total += PRICES[unit_type] * count + total += unit_type.price * count return total @@ -544,24 +545,16 @@ class ControlPoint(MissionTarget, ABC): destination.control_point.base.commission_units({unit_type: 1}) destination = heapq.heappushpop(destinations, destination) - def capture_aircraft( - self, game: Game, airframe: Type[FlyingType], count: int - ) -> None: - try: - value = PRICES[airframe] * count - except KeyError: - logging.exception(f"Unknown price for {airframe.id}") - return - + def capture_aircraft(self, game: Game, airframe: AircraftType, count: int) -> None: + value = airframe.price * count game.adjust_budget(value, player=not self.captured) game.message( - f"No valid retreat destination in range of {self.name} for " - f"{airframe.id}. {count} aircraft have been captured and sold for " - f"${value}M." + f"No valid retreat destination in range of {self.name} for {airframe}" + f"{count} aircraft have been captured and sold for ${value}M." ) def aircraft_retreat_destination( - self, game: Game, airframe: Type[FlyingType] + self, game: Game, airframe: AircraftType ) -> Optional[ControlPoint]: closest = ObjectiveDistanceCache.get_closest_airfields(self) # TODO: Should be airframe dependent. @@ -579,10 +572,10 @@ class ControlPoint(MissionTarget, ABC): return None def _retreat_air_units( - self, game: Game, airframe: Type[FlyingType], count: int + self, game: Game, airframe: AircraftType, count: int ) -> None: while count: - logging.debug(f"Retreating {count} {airframe.id} from {self.name}") + 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) @@ -618,16 +611,16 @@ class ControlPoint(MissionTarget, ABC): self.base.set_strength_to_minimum() @abstractmethod - def can_operate(self, aircraft: Type[FlyingType]) -> bool: + def can_operate(self, aircraft: AircraftType) -> bool: ... - def aircraft_transferring(self, game: Game) -> dict[Type[FlyingType], int]: + def aircraft_transferring(self, game: Game) -> dict[AircraftType, int]: if self.captured: ato = game.blue_ato else: ato = game.red_ato - transferring: defaultdict[Type[FlyingType], int] = defaultdict(int) + transferring: defaultdict[AircraftType, int] = defaultdict(int) for package in ato.packages: for flight in package.flights: if flight.departure == flight.arrival: @@ -692,7 +685,7 @@ class ControlPoint(MissionTarget, ABC): def allocated_aircraft(self, game: Game) -> AircraftAllocations: on_order = {} for unit_bought, count in self.pending_unit_deliveries.units.items(): - if issubclass(unit_bought, FlyingType): + if isinstance(unit_bought, AircraftType): on_order[unit_bought] = count return AircraftAllocations( @@ -704,7 +697,7 @@ class ControlPoint(MissionTarget, ABC): ) -> GroundUnitAllocations: on_order = {} for unit_bought, count in self.pending_unit_deliveries.units.items(): - if issubclass(unit_bought, VehicleType): + if type(unit_bought) == type and issubclass(unit_bought, VehicleType): on_order[unit_bought] = count transferring: dict[Type[VehicleType], int] = defaultdict(int) @@ -788,7 +781,7 @@ class Airfield(ControlPoint): self.airport = airport self._runway_status = RunwayStatus() - def can_operate(self, aircraft: FlyingType) -> bool: + def can_operate(self, aircraft: AircraftType) -> bool: # TODO: Allow helicopters. # Need to implement ground spawns so the helos don't use the runway. # TODO: Allow harrier. @@ -972,8 +965,8 @@ class Carrier(NavalControlPoint): def is_carrier(self): return True - def can_operate(self, aircraft: FlyingType) -> bool: - return aircraft in db.CARRIER_CAPABLE + def can_operate(self, aircraft: AircraftType) -> bool: + return aircraft.carrier_capable @property def total_aircraft_parking(self) -> int: @@ -1006,8 +999,8 @@ class Lha(NavalControlPoint): def is_lha(self) -> bool: return True - def can_operate(self, aircraft: FlyingType) -> bool: - return aircraft in db.LHA_CAPABLE + def can_operate(self, aircraft: AircraftType) -> bool: + return aircraft.lha_capable @property def total_aircraft_parking(self) -> int: @@ -1046,7 +1039,7 @@ class OffMapSpawn(ControlPoint): def total_aircraft_parking(self) -> int: return 1000 - def can_operate(self, aircraft: FlyingType) -> bool: + def can_operate(self, aircraft: AircraftType) -> bool: return True @property @@ -1117,7 +1110,7 @@ class Fob(ControlPoint): def total_aircraft_parking(self) -> int: return 0 - def can_operate(self, aircraft: FlyingType) -> bool: + def can_operate(self, aircraft: AircraftType) -> bool: return False @property diff --git a/game/transfers.py b/game/transfers.py index 8d26113a..0a356047 100644 --- a/game/transfers.py +++ b/game/transfers.py @@ -18,8 +18,9 @@ from typing import ( ) from dcs.mapping import Point -from dcs.unittype import FlyingType, VehicleType +from dcs.unittype import VehicleType +from game.dcs.aircrafttype import AircraftType from game.procurement import AircraftProcurementRequest from game.squadrons import Squadron from game.theater import ControlPoint, MissionTarget @@ -29,7 +30,7 @@ from game.theater.transitnetwork import ( ) from game.utils import meters, nautical_miles from gen.ato import Package -from gen.flights.ai_flight_planner_db import TRANSPORT_CAPABLE +from gen.flights.ai_flight_planner_db import TRANSPORT_CAPABLE, aircraft_for_task from gen.flights.closestairfields import ObjectiveDistanceCache from gen.flights.flight import Flight, FlightType from gen.flights.flightplan import FlightPlanBuilder @@ -191,9 +192,9 @@ class AirliftPlanner: self.package = Package(target=next_stop, auto_asap=True) def compatible_with_mission( - self, unit_type: Type[FlyingType], airfield: ControlPoint + self, unit_type: AircraftType, airfield: ControlPoint ) -> bool: - if not unit_type in TRANSPORT_CAPABLE: + if unit_type not in aircraft_for_task(FlightType.TRANSPORT): return False if not self.transfer.origin.can_operate(unit_type): return False @@ -201,7 +202,7 @@ class AirliftPlanner: return False # Cargo planes have no maximum range. - if not unit_type.helicopter: + if not unit_type.dcs_unit_type.helicopter: return True # A helicopter that is transport capable and able to operate at both bases. Need @@ -254,9 +255,11 @@ class AirliftPlanner: self, squadron: Squadron, inventory: ControlPointAircraftInventory ) -> int: available = inventory.available(squadron.aircraft) - capacity_each = 1 if squadron.aircraft.helicopter else 2 + capacity_each = 1 if squadron.aircraft.dcs_unit_type.helicopter else 2 required = math.ceil(self.transfer.size / capacity_each) - flight_size = min(required, available, squadron.aircraft.group_size_max) + flight_size = min( + required, available, squadron.aircraft.dcs_unit_type.group_size_max + ) capacity = flight_size * capacity_each if capacity < self.transfer.size: diff --git a/game/unitdelivery.py b/game/unitdelivery.py index fd250825..06a4f7ed 100644 --- a/game/unitdelivery.py +++ b/game/unitdelivery.py @@ -3,12 +3,13 @@ from __future__ import annotations import logging from collections import defaultdict from dataclasses import dataclass -from typing import Dict, Optional, TYPE_CHECKING, Type +from typing import Dict, Optional, TYPE_CHECKING, Type, Any from dcs.unittype import UnitType, VehicleType from game.theater import ControlPoint from .db import PRICES +from .dcs.aircrafttype import AircraftType from .theater.transitnetwork import ( NoPathError, TransitNetwork, @@ -24,21 +25,24 @@ class GroundUnitSource: control_point: ControlPoint +AircraftOrVehicleType = Any + + class PendingUnitDeliveries: def __init__(self, destination: ControlPoint) -> None: self.destination = destination # Maps unit type to order quantity. - self.units: Dict[Type[UnitType], int] = defaultdict(int) + self.units: Dict[AircraftOrVehicleType, int] = defaultdict(int) def __str__(self) -> str: return f"Pending delivery to {self.destination}" - def order(self, units: Dict[Type[UnitType], int]) -> None: + def order(self, units: Dict[AircraftOrVehicleType, int]) -> None: for k, v in units.items(): self.units[k] += v - def sell(self, units: Dict[Type[UnitType], int]) -> None: + def sell(self, units: Dict[AircraftOrVehicleType, int]) -> None: for k, v in units.items(): self.units[k] -= v @@ -57,13 +61,13 @@ class PendingUnitDeliveries: logging.info(f"Refunding {count} {unit_type.id} at {self.destination.name}") game.adjust_budget(price * count, player=self.destination.captured) - def pending_orders(self, unit_type: Type[UnitType]) -> int: + def pending_orders(self, unit_type: AircraftOrVehicleType) -> 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: Type[UnitType]) -> int: + def available_next_turn(self, unit_type: AircraftOrVehicleType) -> int: current_units = self.destination.base.total_units_of_type(unit_type) return self.pending_orders(unit_type) + current_units @@ -77,15 +81,20 @@ class PendingUnitDeliveries: self.refund_all(game) return - bought_units: Dict[Type[UnitType], int] = {} + bought_units: Dict[AircraftOrVehicleType, int] = {} units_needing_transfer: Dict[Type[VehicleType], int] = {} - sold_units: Dict[Type[UnitType], int] = {} + sold_units: Dict[AircraftOrVehicleType, int] = {} for unit_type, count in self.units.items(): coalition = "Ally" if self.destination.captured else "Enemy" - name = unit_type.id + + if isinstance(unit_type, AircraftType): + name = unit_type.name + else: + name = unit_type.id if ( - issubclass(unit_type, VehicleType) + type(unit_type) == type + and issubclass(unit_type, VehicleType) and self.destination != ground_unit_source ): source = ground_unit_source diff --git a/gen/aircraft.py b/gen/aircraft.py index 72d279fa..e20d0f89 100644 --- a/gen/aircraft.py +++ b/gen/aircraft.py @@ -12,30 +12,17 @@ from dcs.action import AITaskPush, ActivateGroup from dcs.condition import CoalitionHasAirdrome, TimeAfter from dcs.country import Country from dcs.flyingunit import FlyingUnit -from dcs.helicopters import UH_1H, helicopter_map from dcs.mapping import Point from dcs.mission import Mission, StartType from dcs.planes import ( AJS37, B_17G, B_52H, - Bf_109K_4, C_101CC, C_101EB, - FW_190A8, - FW_190D9, F_14B, - I_16, JF_17, - Ju_88A4, - P_47D_30, - P_47D_30bl1, - P_47D_40, - P_51D, - P_51D_30_NA, PlaneType, - SpitfireLFMkIX, - SpitfireLFMkIXCW, Su_33, Tu_22M3, ) @@ -76,12 +63,12 @@ from dcs.terrain.terrain import Airport, NoParkingSlotError from dcs.triggers import Event, TriggerOnce, TriggerRule from dcs.unit import Unit, Skill from dcs.unitgroup import FlyingGroup, ShipGroup, StaticGroup -from dcs.unittype import FlyingType, UnitType +from dcs.unittype import FlyingType from game import db from game.data.cap_capabilities_db import GUNFIGHTERS from game.data.weapons import Pylon -from game.db import GUN_RELIANT_AIRFRAMES +from game.dcs.aircrafttype import AircraftType from game.factions.faction import Faction from game.settings import Settings from game.squadrons import Pilot @@ -105,7 +92,7 @@ from gen.flights.flight import ( FlightWaypoint, FlightWaypointType, ) -from gen.radios import MHz, Radio, RadioFrequency, RadioRegistry, get_radio +from gen.radios import RadioFrequency, RadioRegistry from gen.runways import RunwayData from gen.tacan import TacanBand, TacanRegistry from .airsupportgen import AirSupport, AwacsInfo, TankerInfo @@ -131,16 +118,6 @@ RTB_ALTITUDE = meters(800) RTB_DISTANCE = 5000 HELI_ALT = 500 -# Note that fallback radio channels will *not* be reserved. It's possible that -# flights using these will overlap with other channels. This is because we would -# need to make sure we fell back to a frequency that is not used by any beacon -# or ATC, which we don't have the information to predict. Deal with the minor -# annoyance for now since we'll be fleshing out radio info soon enough. -ALLIES_WW2_CHANNEL = MHz(124) -GERMAN_WW2_CHANNEL = MHz(40) -HELICOPTER_CHANNEL = MHz(127) -UHF_FALLBACK_CHANNEL = MHz(251) - TARGET_WAYPOINTS = ( FlightWaypointType.TARGET_GROUP_LOC, FlightWaypointType.TARGET_POINT, @@ -148,121 +125,6 @@ TARGET_WAYPOINTS = ( ) -# TODO: Get radio information for all the special cases. -def get_fallback_channel(unit_type: UnitType) -> RadioFrequency: - if unit_type in helicopter_map.values() and unit_type != UH_1H: - return HELICOPTER_CHANNEL - - german_ww2_aircraft = [ - Bf_109K_4, - FW_190A8, - FW_190D9, - Ju_88A4, - ] - - if unit_type in german_ww2_aircraft: - return GERMAN_WW2_CHANNEL - - allied_ww2_aircraft = [ - I_16, - P_47D_30, - P_47D_30bl1, - P_47D_40, - P_51D, - P_51D_30_NA, - SpitfireLFMkIX, - SpitfireLFMkIXCW, - ] - - if unit_type in allied_ww2_aircraft: - return ALLIES_WW2_CHANNEL - - return UHF_FALLBACK_CHANNEL - - -class ChannelNamer: - """Base class allowing channel name customization per-aircraft. - - Most aircraft will want to customize this behavior, but the default is - reasonable for any aircraft with numbered radios. - """ - - @staticmethod - def channel_name(radio_id: int, channel_id: int) -> str: - """Returns the name of the channel for the given radio and channel.""" - return f"COMM{radio_id} Ch {channel_id}" - - -class SingleRadioChannelNamer(ChannelNamer): - """Channel namer for the aircraft with only a single radio. - - Aircraft like the MiG-19P and the MiG-21bis only have a single radio, so - it's not necessary for us to name the radio when naming the channel. - """ - - @staticmethod - def channel_name(radio_id: int, channel_id: int) -> str: - return f"Ch {channel_id}" - - -class HueyChannelNamer(ChannelNamer): - """Channel namer for the UH-1H.""" - - @staticmethod - def channel_name(radio_id: int, channel_id: int) -> str: - return f"COM3 Ch {channel_id}" - - -class MirageChannelNamer(ChannelNamer): - """Channel namer for the M-2000.""" - - @staticmethod - def channel_name(radio_id: int, channel_id: int) -> str: - radio_name = ["V/UHF", "UHF"][radio_id - 1] - return f"{radio_name} Ch {channel_id}" - - -class TomcatChannelNamer(ChannelNamer): - """Channel namer for the F-14.""" - - @staticmethod - def channel_name(radio_id: int, channel_id: int) -> str: - radio_name = ["UHF", "VHF/UHF"][radio_id - 1] - return f"{radio_name} Ch {channel_id}" - - -class ViggenChannelNamer(ChannelNamer): - """Channel namer for the AJS37.""" - - @staticmethod - def channel_name(radio_id: int, channel_id: int) -> str: - if channel_id >= 4: - channel_letter = "EFGH"[channel_id - 4] - return f"FR 24 {channel_letter}" - return f"FR 22 Special {channel_id}" - - -class ViperChannelNamer(ChannelNamer): - """Channel namer for the F-16.""" - - @staticmethod - def channel_name(radio_id: int, channel_id: int) -> str: - return f"COM{radio_id} Ch {channel_id}" - - -class SCR522ChannelNamer(ChannelNamer): - """ - Channel namer for P-51 & P-47D - """ - - @staticmethod - def channel_name(radio_id: int, channel_id: int) -> str: - if channel_id > 3: - return "?" - else: - return f"Button " + "ABCD"[channel_id - 1] - - @dataclass(frozen=True) class ChannelAssignment: radio_id: int @@ -276,9 +138,6 @@ class FlightData: #: The package that the flight belongs to. package: Package - #: The country that the flight belongs to. - country: str - flight_type: FlightType #: All units in the flight. @@ -319,7 +178,7 @@ class FlightData: def __init__( self, package: Package, - country: str, + aircraft_type: AircraftType, flight_type: FlightType, units: List[FlyingUnit], size: int, @@ -335,7 +194,7 @@ class FlightData: custom_name: Optional[str], ) -> None: self.package = package - self.country = country + self.aircraft_type = aircraft_type self.flight_type = flight_type self.units = units self.size = size @@ -357,11 +216,6 @@ class FlightData: """List of playable units in the flight.""" return [u for u in self.units if u.is_human()] - @property - def aircraft_type(self) -> FlyingType: - """Returns the type of aircraft in this flight.""" - return self.units[0].unit_type - def num_radio_channels(self, radio_id: int) -> int: """Returns the number of preset channels for the given radio.""" # Note: pydcs only initializes the radio presets for client slots. @@ -387,302 +241,6 @@ class FlightData: ) -class RadioChannelAllocator: - """Base class for radio channel allocators.""" - - def assign_channels_for_flight( - self, flight: FlightData, air_support: AirSupport - ) -> None: - """Assigns mission frequencies to preset channels for the flight.""" - raise NotImplementedError - - -@dataclass(frozen=True) -class CommonRadioChannelAllocator(RadioChannelAllocator): - """Radio channel allocator suitable for most aircraft. - - Most of the aircraft with preset channels available have one or more radios - with 20 or more channels available (typically per-radio, but this is not the - case for the JF-17). - """ - - #: Index of the radio used for intra-flight communications. Matches the - #: index of the panel_radio field of the pydcs.dcs.planes object. - inter_flight_radio_index: Optional[int] - - #: Index of the radio used for intra-flight communications. Matches the - #: index of the panel_radio field of the pydcs.dcs.planes object. - intra_flight_radio_index: Optional[int] - - def assign_channels_for_flight( - self, flight: FlightData, air_support: AirSupport - ) -> None: - if self.intra_flight_radio_index is not None: - flight.assign_channel( - self.intra_flight_radio_index, 1, flight.intra_flight_channel - ) - - if self.inter_flight_radio_index is None: - return - - # For cases where the inter-flight and intra-flight radios share presets - # (the JF-17 only has one set of channels, even though it can use two - # channels simultaneously), start assigning inter-flight channels at 2. - radio_id = self.inter_flight_radio_index - if self.intra_flight_radio_index == radio_id: - first_channel = 2 - else: - first_channel = 1 - - last_channel = flight.num_radio_channels(radio_id) - channel_alloc = iter(range(first_channel, last_channel + 1)) - - if flight.departure.atc is not None: - flight.assign_channel(radio_id, next(channel_alloc), flight.departure.atc) - - # TODO: If there ever are multiple AWACS, limit to mission relevant. - for awacs in air_support.awacs: - flight.assign_channel(radio_id, next(channel_alloc), awacs.freq) - - if flight.arrival != flight.departure and flight.arrival.atc is not None: - flight.assign_channel(radio_id, next(channel_alloc), flight.arrival.atc) - - try: - # TODO: Skip incompatible tankers. - for tanker in air_support.tankers: - flight.assign_channel(radio_id, next(channel_alloc), tanker.freq) - - if flight.divert is not None and flight.divert.atc is not None: - flight.assign_channel(radio_id, next(channel_alloc), flight.divert.atc) - except StopIteration: - # Any remaining channels are nice-to-haves, but not necessary for - # the few aircraft with a small number of channels available. - pass - - -@dataclass(frozen=True) -class NoOpChannelAllocator(RadioChannelAllocator): - """Channel allocator for aircraft that don't support preset channels.""" - - def assign_channels_for_flight( - self, flight: FlightData, air_support: AirSupport - ) -> None: - pass - - -@dataclass(frozen=True) -class FarmerRadioChannelAllocator(RadioChannelAllocator): - """Preset channel allocator for the MiG-19P.""" - - def assign_channels_for_flight( - self, flight: FlightData, air_support: AirSupport - ) -> None: - # The Farmer only has 6 preset channels. It also only has a VHF radio, - # and currently our ATC data and AWACS are only in the UHF band. - radio_id = 1 - flight.assign_channel(radio_id, 1, flight.intra_flight_channel) - # TODO: Assign 4-6 to VHF frequencies of departure, arrival, and divert. - # TODO: Assign 2 and 3 to AWACS if it is VHF. - - -@dataclass(frozen=True) -class ViggenRadioChannelAllocator(RadioChannelAllocator): - """Preset channel allocator for the AJS37.""" - - def assign_channels_for_flight( - self, flight: FlightData, air_support: AirSupport - ) -> None: - # The Viggen's preset channels are handled differently from other - # aircraft. The aircraft automatically configures channels for every - # allied flight in the game (including AWACS) and for every airfield. As - # such, we don't need to allocate any of those. There are seven presets - # we can modify, however: three channels for the main radio intended for - # communication with wingmen, and four emergency channels for the backup - # radio. We'll set the first channel of the main radio to the - # intra-flight channel, and the first three emergency channels to each - # of the flight plan's airfields. The fourth emergency channel is always - # the guard channel. - radio_id = 1 - flight.assign_channel(radio_id, 1, flight.intra_flight_channel) - if flight.departure.atc is not None: - flight.assign_channel(radio_id, 4, flight.departure.atc) - if flight.arrival.atc is not None: - flight.assign_channel(radio_id, 5, flight.arrival.atc) - # TODO: Assign divert to 6 when we support divert airfields. - - -@dataclass(frozen=True) -class SCR522RadioChannelAllocator(RadioChannelAllocator): - """Preset channel allocator for the SCR522 WW2 radios. (4 channels)""" - - def assign_channels_for_flight( - self, flight: FlightData, air_support: AirSupport - ) -> None: - radio_id = 1 - flight.assign_channel(radio_id, 1, flight.intra_flight_channel) - if flight.departure.atc is not None: - flight.assign_channel(radio_id, 2, flight.departure.atc) - if flight.arrival.atc is not None: - flight.assign_channel(radio_id, 3, flight.arrival.atc) - - # TODO : Some GCI on Channel 4 ? - - -@dataclass(frozen=True) -class AircraftData: - """Additional aircraft data not exposed by pydcs.""" - - #: The type of radio used for inter-flight communications. - inter_flight_radio: Radio - - #: The type of radio used for intra-flight communications. - intra_flight_radio: Radio - - #: The radio preset channel allocator, if the aircraft supports channel - #: presets. If the aircraft does not support preset channels, this will be - #: None. - channel_allocator: Optional[RadioChannelAllocator] - - #: Defines how channels should be named when printed in the kneeboard. - channel_namer: Type[ChannelNamer] = ChannelNamer - - -# Indexed by the id field of the pydcs PlaneType. -AIRCRAFT_DATA: Dict[str, AircraftData] = { - "A-10C": AircraftData( - inter_flight_radio=get_radio("AN/ARC-164"), - # VHF for intraflight is not accepted anymore by DCS - # (see https://forums.eagle.ru/showthread.php?p=4499738). - intra_flight_radio=get_radio("AN/ARC-164"), - channel_allocator=NoOpChannelAllocator(), - ), - "AJS37": AircraftData( - # The AJS37 has somewhat unique radio configuration. Two backup radio - # (FR 24) can only operate simultaneously with the main radio in guard - # mode. As such, we only use the main radio for both inter- and intra- - # flight communication. - inter_flight_radio=get_radio("FR 22"), - intra_flight_radio=get_radio("FR 22"), - channel_allocator=ViggenRadioChannelAllocator(), - channel_namer=ViggenChannelNamer, - ), - "AV8BNA": AircraftData( - inter_flight_radio=get_radio("AN/ARC-210"), - intra_flight_radio=get_radio("AN/ARC-210"), - channel_allocator=CommonRadioChannelAllocator( - inter_flight_radio_index=2, intra_flight_radio_index=1 - ), - ), - "F-14B": AircraftData( - inter_flight_radio=get_radio("AN/ARC-159"), - intra_flight_radio=get_radio("AN/ARC-182"), - channel_allocator=CommonRadioChannelAllocator( - inter_flight_radio_index=1, intra_flight_radio_index=2 - ), - channel_namer=TomcatChannelNamer, - ), - "F-16C_50": AircraftData( - inter_flight_radio=get_radio("AN/ARC-164"), - intra_flight_radio=get_radio("AN/ARC-222"), - # COM2 is the AN/ARC-222, which is the VHF radio we want to use for - # intra-flight communication to leave COM1 open for UHF inter-flight. - channel_allocator=CommonRadioChannelAllocator( - inter_flight_radio_index=1, intra_flight_radio_index=2 - ), - channel_namer=ViperChannelNamer, - ), - "FA-18C_hornet": AircraftData( - inter_flight_radio=get_radio("AN/ARC-210"), - intra_flight_radio=get_radio("AN/ARC-210"), - # DCS will clobber channel 1 of the first radio compatible with the - # flight's assigned frequency. Since the F/A-18's two radios are both - # AN/ARC-210s, radio 1 will be compatible regardless of which frequency - # is assigned, so we must use radio 1 for the intra-flight radio. - channel_allocator=CommonRadioChannelAllocator( - inter_flight_radio_index=2, intra_flight_radio_index=1 - ), - ), - "JF-17": AircraftData( - inter_flight_radio=get_radio("R&S M3AR UHF"), - intra_flight_radio=get_radio("R&S M3AR VHF"), - channel_allocator=CommonRadioChannelAllocator( - inter_flight_radio_index=1, intra_flight_radio_index=1 - ), - # Same naming pattern as the Viper, so just reuse that. - channel_namer=ViperChannelNamer, - ), - "Ka-50": AircraftData( - inter_flight_radio=get_radio("R-800L1"), - intra_flight_radio=get_radio("R-800L1"), - # The R-800L1 doesn't have preset channels, and the other radio is for - # communications with FAC and ground units, which don't currently have - # radios assigned, so no channels to configure. - channel_allocator=NoOpChannelAllocator(), - ), - "M-2000C": AircraftData( - inter_flight_radio=get_radio("TRT ERA 7000 V/UHF"), - intra_flight_radio=get_radio("TRT ERA 7200 UHF"), - channel_allocator=CommonRadioChannelAllocator( - inter_flight_radio_index=1, intra_flight_radio_index=2 - ), - channel_namer=MirageChannelNamer, - ), - "MiG-15bis": AircraftData( - inter_flight_radio=get_radio("RSI-6K HF"), - intra_flight_radio=get_radio("RSI-6K HF"), - channel_allocator=NoOpChannelAllocator(), - ), - "MiG-19P": AircraftData( - inter_flight_radio=get_radio("RSIU-4V"), - intra_flight_radio=get_radio("RSIU-4V"), - channel_allocator=FarmerRadioChannelAllocator(), - channel_namer=SingleRadioChannelNamer, - ), - "MiG-21Bis": AircraftData( - inter_flight_radio=get_radio("RSIU-5V"), - intra_flight_radio=get_radio("RSIU-5V"), - channel_allocator=CommonRadioChannelAllocator( - inter_flight_radio_index=1, intra_flight_radio_index=1 - ), - channel_namer=SingleRadioChannelNamer, - ), - "P-51D": AircraftData( - inter_flight_radio=get_radio("SCR522"), - intra_flight_radio=get_radio("SCR522"), - channel_allocator=CommonRadioChannelAllocator( - inter_flight_radio_index=1, intra_flight_radio_index=1 - ), - channel_namer=SCR522ChannelNamer, - ), - "UH-1H": AircraftData( - inter_flight_radio=get_radio("AN/ARC-51BX"), - # Ideally this would use the AN/ARC-131 because that radio is supposed - # to be used for flight comms, but DCS won't allow it as the flight's - # frequency, nor will it allow the AN/ARC-134. - intra_flight_radio=get_radio("AN/ARC-51BX"), - channel_allocator=CommonRadioChannelAllocator( - inter_flight_radio_index=1, intra_flight_radio_index=1 - ), - channel_namer=HueyChannelNamer, - ), - "F-22A": AircraftData( - inter_flight_radio=get_radio("SCR-522"), - intra_flight_radio=get_radio("SCR-522"), - channel_allocator=None, - channel_namer=SCR522ChannelNamer, - ), - "JAS39Gripen": AircraftData( - inter_flight_radio=get_radio("R&S Series 6000"), - intra_flight_radio=get_radio("R&S Series 6000"), - channel_allocator=None, - ), -} -AIRCRAFT_DATA["A-10C_2"] = AIRCRAFT_DATA["A-10C"] -AIRCRAFT_DATA["P-51D-30-NA"] = AIRCRAFT_DATA["P-51D"] -AIRCRAFT_DATA["P-47D-30"] = AIRCRAFT_DATA["P-51D"] -AIRCRAFT_DATA["JAS39Gripen_AG"] = AIRCRAFT_DATA["JAS39Gripen"] - - class AircraftConflictGenerator: def __init__( self, @@ -718,21 +276,6 @@ class AircraftConflictGenerator: total += flight.client_count return total - def get_intra_flight_channel(self, airframe: UnitType) -> RadioFrequency: - """Allocates an intra-flight channel to a group. - - Args: - airframe: The type of aircraft a channel should be allocated for. - - Returns: - The frequency of the intra-flight channel. - """ - try: - aircraft_data = AIRCRAFT_DATA[airframe.id] - return self.radio_registry.alloc_for_radio(aircraft_data.intra_flight_radio) - except KeyError: - return get_fallback_channel(airframe) - @staticmethod def _start_type(start_type: str) -> StartType: if start_type == "Runway": @@ -838,7 +381,7 @@ class AircraftConflictGenerator: ): channel = self.radio_registry.alloc_uhf() else: - channel = self.get_intra_flight_channel(unit_type) + channel = flight.unit_type.alloc_flight_radio(self.radio_registry) group.set_frequency(channel.mhz) divert = None @@ -848,7 +391,7 @@ class AircraftConflictGenerator: self.flights.append( FlightData( package=package, - country=self.game.faction_for(player=flight.departure.captured).country, + aircraft_type=flight.unit_type, flight_type=flight.flight_type, units=group.units, size=len(group.units), @@ -894,12 +437,11 @@ class AircraftConflictGenerator: callsign = callsign_for_support_unit(group) tacan = self.tacan_registy.alloc_for_band(TacanBand.Y) - variant = db.unit_type_name(flight.flight_plan.flight.unit_type) self.air_support.tankers.append( TankerInfo( group_name=str(group.name), callsign=callsign, - variant=variant, + variant=flight.unit_type.name, freq=channel, tacan=tacan, start_time=flight.flight_plan.patrol_start_time, @@ -958,7 +500,7 @@ class AircraftConflictGenerator: group = self.m.flight_group( country=side, name=name, - aircraft_type=flight.unit_type, + aircraft_type=flight.unit_type.dcs_unit_type, airport=None, position=pos, altitude=alt.meters, @@ -1092,7 +634,7 @@ class AircraftConflictGenerator: control_point: Airfield, country: Country, faction: Faction, - aircraft: Type[FlyingType], + aircraft: AircraftType, number: int, ) -> None: for _ in range(number): @@ -1114,7 +656,7 @@ class AircraftConflictGenerator: group = self._generate_at_airport( name=namegen.next_aircraft_name(country, control_point.id, flight), side=country, - unit_type=aircraft, + unit_type=aircraft.dcs_unit_type, count=1, start_type="Cold", airport=control_point.airport, @@ -1188,7 +730,7 @@ class AircraftConflictGenerator: group = self._generate_at_group( name=name, side=country, - unit_type=flight.unit_type, + unit_type=flight.unit_type.dcs_unit_type, count=flight.count, start_type=flight.start_type, at=self.m.find_group(group_name), @@ -1201,7 +743,7 @@ class AircraftConflictGenerator: group = self._generate_at_airport( name=name, side=country, - unit_type=flight.unit_type, + unit_type=flight.unit_type.dcs_unit_type, count=flight.count, start_type=flight.start_type, airport=cp.airport, @@ -1243,7 +785,7 @@ class AircraftConflictGenerator: if flight.client_count > 0: return True - return flight.unit_type in GUN_RELIANT_AIRFRAMES + return flight.unit_type.always_keeps_gun def configure_behavior( self, @@ -1283,7 +825,7 @@ class AircraftConflictGenerator: @staticmethod def configure_eplrs(group: FlyingGroup, flight: Flight) -> None: if hasattr(flight.unit_type, "eplrs"): - if flight.unit_type.eplrs: + if flight.unit_type.dcs_unit_type.eplrs: group.points[0].tasks.append(EPLRS(group.id)) def configure_cap( diff --git a/gen/airsupportgen.py b/gen/airsupportgen.py index c3f97791..23ff09a3 100644 --- a/gen/airsupportgen.py +++ b/gen/airsupportgen.py @@ -103,13 +103,13 @@ class AirSupportConflictGenerator: ) if not self.game.settings.disable_legacy_tanker: - fallback_tanker_number = 0 for i, tanker_unit_type in enumerate( self.game.faction_for(player=True).tankers ): - alt, airspeed = self._get_tanker_params(tanker_unit_type) + # TODO: Make loiter altitude a property of the unit type. + alt, airspeed = self._get_tanker_params(tanker_unit_type.dcs_unit_type) variant = db.unit_type_name(tanker_unit_type) freq = self.radio_registry.alloc_uhf() tacan = self.tacan_registry.alloc_for_band(TacanBand.Y) diff --git a/gen/armor.py b/gen/armor.py index 3b9f93ee..dd16f94f 100644 --- a/gen/armor.py +++ b/gen/armor.py @@ -28,6 +28,7 @@ from dcs.unit import Vehicle from dcs.unitgroup import VehicleGroup from dcs.unittype import VehicleType from game import db +from game.dcs.aircrafttype import AircraftType from game.unitmap import UnitMap from game.utils import heading_sum, opposite_heading from game.theater.controlpoint import ControlPoint @@ -174,14 +175,14 @@ class GroundConflictGenerator: n = "JTAC" + str(self.conflict.blue_cp.id) + str(self.conflict.red_cp.id) code = 1688 - len(self.jtacs) - utype = MQ_9_Reaper - if self.game.player_faction.jtac_unit is not None: - utype = self.game.player_faction.jtac_unit + utype = self.game.player_faction.jtac_unit + if self.game.player_faction.jtac_unit is None: + utype = AircraftType.named("MQ-9 Reaper") jtac = self.mission.flight_group( country=self.mission.country(self.game.player_country), name=n, - aircraft_type=utype, + aircraft_type=utype.dcs_unit_type, position=position[0], airport=None, altitude=5000, diff --git a/gen/flights/ai_flight_planner.py b/gen/flights/ai_flight_planner.py index 2ba8cfec..7dfe061b 100644 --- a/gen/flights/ai_flight_planner.py +++ b/gen/flights/ai_flight_planner.py @@ -17,14 +17,10 @@ from typing import ( Set, TYPE_CHECKING, Tuple, - Type, TypeVar, - Union, ) -from dcs.unittype import FlyingType - -from game.factions.faction import Faction +from game.dcs.aircrafttype import AircraftType from game.infos.information import Information from game.procurement import AircraftProcurementRequest from game.profiling import logged_duration, MultiEventTracer @@ -256,7 +252,7 @@ class PackageBuilder: return True def find_divert_field( - self, aircraft: Type[FlyingType], arrival: ControlPoint + self, aircraft: AircraftType, arrival: ControlPoint ) -> Optional[ControlPoint]: divert_limit = nautical_miles(150) for airfield in self.closest_airfields.operational_airfields_within( @@ -867,7 +863,7 @@ class CoalitionMissionPlanner: for cp in self.objective_finder.friendly_control_points(): inventory = self.game.aircraft_inventory.for_control_point(cp) for aircraft, available in inventory.all_aircraft: - self.message("Unused aircraft", f"{available} {aircraft.id} from {cp}") + self.message("Unused aircraft", f"{available} {aircraft} from {cp}") def plan_flight( self, diff --git a/gen/flights/ai_flight_planner_db.py b/gen/flights/ai_flight_planner_db.py index 36486434..8ce78d13 100644 --- a/gen/flights/ai_flight_planner_db.py +++ b/gen/flights/ai_flight_planner_db.py @@ -104,6 +104,7 @@ from dcs.planes import ( ) from dcs.unittype import FlyingType +from game.dcs.aircrafttype import AircraftType from gen.flights.flight import FlightType from pydcs_extensions.a4ec.a4ec import A_4E_C from pydcs_extensions.f22a.f22a import F_22A @@ -415,7 +416,7 @@ REFUELING_CAPABALE = [ ] -def aircraft_for_task(task: FlightType) -> List[Type[FlyingType]]: +def dcs_types_for_task(task: FlightType) -> list[Type[FlyingType]]: cap_missions = (FlightType.BARCAP, FlightType.TARCAP, FlightType.SWEEP) if task in cap_missions: return CAP_CAPABLE @@ -450,7 +451,15 @@ def aircraft_for_task(task: FlightType) -> List[Type[FlyingType]]: return [] -def tasks_for_aircraft(aircraft: Type[FlyingType]) -> list[FlightType]: +def aircraft_for_task(task: FlightType) -> list[AircraftType]: + dcs_types = dcs_types_for_task(task) + types: list[AircraftType] = [] + for dcs_type in dcs_types: + types.extend(AircraftType.for_dcs_type(dcs_type)) + return types + + +def tasks_for_aircraft(aircraft: AircraftType) -> list[FlightType]: tasks = [] for task in FlightType: if aircraft in aircraft_for_task(task): diff --git a/gen/flights/flight.py b/gen/flights/flight.py index bbdb6cd0..cf5c011f 100644 --- a/gen/flights/flight.py +++ b/gen/flights/flight.py @@ -1,16 +1,15 @@ from __future__ import annotations -from dataclasses import dataclass from datetime import timedelta from enum import Enum -from typing import List, Optional, TYPE_CHECKING, Type, Union +from typing import List, Optional, TYPE_CHECKING, Union from dcs.mapping import Point from dcs.point import MovingPoint, PointAction from dcs.unit import Unit -from dcs.unittype import FlyingType from game import db +from game.dcs.aircrafttype import AircraftType from game.squadrons import Pilot, Squadron from game.theater.controlpoint import ControlPoint, MissionTarget from game.utils import Distance, meters @@ -300,7 +299,7 @@ class Flight: return self.roster.player_count @property - def unit_type(self) -> Type[FlyingType]: + def unit_type(self) -> AircraftType: return self.squadron.aircraft @property @@ -325,13 +324,11 @@ class Flight: self.roster.clear() def __repr__(self): - name = db.unit_type_name(self.unit_type) if self.custom_name: - return f"{self.custom_name} {self.count} x {name}" - return f"[{self.flight_type}] {self.count} x {name}" + return f"{self.custom_name} {self.count} x {self.unit_type}" + return f"[{self.flight_type}] {self.count} x {self.unit_type}" def __str__(self): - name = db.unit_get_expanded_info(self.country, self.unit_type, "name") if self.custom_name: - return f"{self.custom_name} {self.count} x {name}" - return f"[{self.flight_type}] {self.count} x {name}" + return f"{self.custom_name} {self.count} x {self.unit_type}" + return f"[{self.flight_type}] {self.count} x {self.unit_type}" diff --git a/gen/flights/loadouts.py b/gen/flights/loadouts.py index 845f7751..0a51245a 100644 --- a/gen/flights/loadouts.py +++ b/gen/flights/loadouts.py @@ -1,11 +1,10 @@ from __future__ import annotations import datetime -from typing import Optional, List, Iterator, Type, TYPE_CHECKING, Mapping - -from dcs.unittype import FlyingType +from typing import Optional, List, Iterator, TYPE_CHECKING, Mapping from game.data.weapons import Weapon, Pylon +from game.dcs.aircrafttype import AircraftType if TYPE_CHECKING: from gen.flights.flight import Flight @@ -27,9 +26,7 @@ class Loadout: def derive_custom(self, name: str) -> Loadout: return Loadout(name, self.pylons, self.date, is_custom=True) - def degrade_for_date( - self, unit_type: Type[FlyingType], date: datetime.date - ) -> Loadout: + def degrade_for_date(self, unit_type: AircraftType, date: datetime.date) -> Loadout: if self.date is not None and self.date <= date: return Loadout(self.name, self.pylons, self.date) @@ -61,7 +58,7 @@ class Loadout: # {"CLSID": class ID, "num": pylon number} # "tasks": List (as a dict) of task IDs the payload is used by. # } - payloads = flight.unit_type.load_payloads() + payloads = flight.unit_type.dcs_unit_type.load_payloads() for payload in payloads.values(): name = payload["name"] pylons = payload["pylons"] @@ -126,8 +123,8 @@ class Loadout: for name in cls.default_loadout_names_for(flight): # This operation is cached, but must be called before load_by_name will # work. - flight.unit_type.load_payloads() - payload = flight.unit_type.loadout_by_name(name) + flight.unit_type.dcs_unit_type.load_payloads() + payload = flight.unit_type.dcs_unit_type.loadout_by_name(name) if payload is not None: return Loadout( name, diff --git a/gen/flights/traveltime.py b/gen/flights/traveltime.py index 6b787f95..25e28ef9 100644 --- a/gen/flights/traveltime.py +++ b/gen/flights/traveltime.py @@ -25,16 +25,13 @@ if TYPE_CHECKING: class GroundSpeed: @classmethod def for_flight(cls, flight: Flight, altitude: Distance) -> Speed: - if not issubclass(flight.unit_type, FlyingType): - raise TypeError("Flight has non-flying unit") - # TODO: Expose both a cruise speed and target speed. # The cruise speed can be used for ascent, hold, join, and RTB to save # on fuel, but mission speed will be fast enough to keep the flight # safer. # DCS's max speed is in kph at 0 MSL. - max_speed = kph(flight.unit_type.max_speed) + max_speed = flight.unit_type.max_speed if max_speed > SPEED_OF_SOUND_AT_SEA_LEVEL: # Aircraft is supersonic. Limit to mach 0.85 to conserve fuel and # account for heavily loaded jets. diff --git a/gen/kneeboard.py b/gen/kneeboard.py index 71544a26..62fd9d25 100644 --- a/gen/kneeboard.py +++ b/gen/kneeboard.py @@ -32,15 +32,15 @@ from typing import Dict, List, Optional, TYPE_CHECKING, Tuple, Iterator from PIL import Image, ImageDraw, ImageFont from dcs.mission import Mission from dcs.unit import Unit -from dcs.unittype import FlyingType from tabulate import tabulate from game.data.alic import AlicCodes from game.db import unit_type_from_name +from game.dcs.aircrafttype import AircraftType from game.theater import ConflictTheater, TheaterGroundObject, LatLon from game.theater.bullseye import Bullseye from game.utils import meters -from .aircraft import AIRCRAFT_DATA, FlightData +from .aircraft import FlightData from .airsupportgen import AwacsInfo, TankerInfo from .briefinggen import CommInfo, JtacInfo, MissionInfoGenerator from .flights.flight import FlightWaypoint, FlightWaypointType, FlightType @@ -142,7 +142,8 @@ class KneeboardPage: """Writes the kneeboard page to the given path.""" raise NotImplementedError - def format_ll(self, ll: LatLon) -> str: + @staticmethod + def format_ll(ll: LatLon) -> str: ns = "N" if ll.latitude >= 0 else "S" ew = "E" if ll.longitude >= 0 else "W" return f"{ll.latitude:.4}°{ns} {ll.longitude:.4}°{ew}" @@ -355,8 +356,9 @@ class BriefingPage(KneeboardPage): if channel is None: return str(frequency) - namer = AIRCRAFT_DATA[self.flight.aircraft_type.id].channel_namer - channel_name = namer.channel_name(channel.radio_id, channel.channel) + channel_name = self.flight.aircraft_type.channel_name( + channel.radio_id, channel.channel + ) return f"{channel_name}\n{frequency}" @@ -452,9 +454,10 @@ class SupportPage(KneeboardPage): if channel is None: return str(frequency) - namer = AIRCRAFT_DATA[self.flight.aircraft_type.id].channel_namer - channel_name = namer.channel_name(channel.radio_id, channel.channel) - return f"{channel_name} {frequency}" + channel_name = self.flight.aircraft_type.channel_name( + channel.radio_id, channel.channel + ) + return f"{channel_name}\n{frequency}" def _format_time(self, time: Optional[datetime.timedelta]) -> str: if time is None: @@ -565,14 +568,14 @@ class KneeboardGenerator(MissionInfoGenerator): temp_dir = Path("kneeboards") temp_dir.mkdir(exist_ok=True) for aircraft, pages in self.pages_by_airframe().items(): - aircraft_dir = temp_dir / aircraft.id + aircraft_dir = temp_dir / aircraft.dcs_unit_type.id aircraft_dir.mkdir(exist_ok=True) for idx, page in enumerate(pages): page_path = aircraft_dir / f"page{idx:02}.png" page.write(page_path) - self.mission.add_aircraft_kneeboard(aircraft, page_path) + self.mission.add_aircraft_kneeboard(aircraft.dcs_unit_type, page_path) - def pages_by_airframe(self) -> Dict[FlyingType, List[KneeboardPage]]: + def pages_by_airframe(self) -> Dict[AircraftType, List[KneeboardPage]]: """Returns a list of kneeboard pages per airframe in the mission. Only client flights will be included, but because DCS does not support @@ -583,7 +586,7 @@ class KneeboardGenerator(MissionInfoGenerator): A dict mapping aircraft types to the list of kneeboard pages for that aircraft. """ - all_flights: Dict[FlyingType, List[KneeboardPage]] = defaultdict(list) + all_flights: Dict[AircraftType, List[KneeboardPage]] = defaultdict(list) for flight in self.flights: if not flight.client_units: continue diff --git a/gen/naming.py b/gen/naming.py index dad364dc..f4964cd2 100644 --- a/gen/naming.py +++ b/gen/naming.py @@ -6,6 +6,7 @@ from dcs.country import Country from dcs.unittype import UnitType from game import db +from game.dcs.aircrafttype import AircraftType from gen.flights.flight import Flight @@ -290,7 +291,7 @@ class NameGenerator: country.id, cls.aircraft_number, parent_base_id, - db.unit_type_name(flight.unit_type), + flight.unit_type.name, ) @classmethod @@ -318,11 +319,9 @@ class NameGenerator: return "awacs|{}|{}|0|".format(country.id, cls.number) @classmethod - def next_tanker_name(cls, country: Country, unit_type: UnitType): + def next_tanker_name(cls, country: Country, unit_type: AircraftType): cls.number += 1 - return "tanker|{}|{}|0|{}".format( - country.id, cls.number, db.unit_type_name(unit_type) - ) + return "tanker|{}|{}|0|{}".format(country.id, cls.number, unit_type.name) @classmethod def next_carrier_name(cls, country: Country): diff --git a/gen/radios.py b/gen/radios.py index 333647df..22968397 100644 --- a/gen/radios.py +++ b/gen/radios.py @@ -153,7 +153,7 @@ def get_radio(name: str) -> Radio: for radio in RADIOS: if radio.name == name: return radio - raise KeyError + raise KeyError(f"Unknown radio: {name}") class RadioRegistry: diff --git a/qt_ui/models.py b/qt_ui/models.py index 0bf5920f..4c4a52fe 100644 --- a/qt_ui/models.py +++ b/qt_ui/models.py @@ -143,7 +143,7 @@ class PackageModel(QAbstractListModel): @staticmethod def icon_for_flight(flight: Flight) -> Optional[QIcon]: """Returns the icon that should be displayed for the flight.""" - name = db.unit_type_name(flight.unit_type) + name = flight.unit_type.dcs_id if name in AIRCRAFT_ICONS: return QIcon(AIRCRAFT_ICONS[name]) return None @@ -402,7 +402,7 @@ class AirWingModel(QAbstractListModel): @staticmethod def icon_for_squadron(squadron: Squadron) -> Optional[QIcon]: """Returns the icon that should be displayed for the squadron.""" - name = db.unit_type_name(squadron.aircraft) + name = squadron.aircraft.dcs_id if name in AIRCRAFT_ICONS: return QIcon(AIRCRAFT_ICONS[name]) return None diff --git a/qt_ui/widgets/combos/QAircraftTypeSelector.py b/qt_ui/widgets/combos/QAircraftTypeSelector.py index b7949aa2..80dfa5b0 100644 --- a/qt_ui/widgets/combos/QAircraftTypeSelector.py +++ b/qt_ui/widgets/combos/QAircraftTypeSelector.py @@ -30,10 +30,7 @@ class QAircraftTypeSelector(QComboBox): self.clear() for aircraft in aircraft_types: if aircraft in aircraft_for_task(mission_type): - self.addItem( - f"{db.unit_get_expanded_info(self.country, aircraft, 'name')}", - userData=aircraft, - ) + self.addItem(f"{aircraft}", userData=aircraft) current_aircraft_index = self.findData(current_aircraft) if current_aircraft_index != -1: self.setCurrentIndex(current_aircraft_index) diff --git a/qt_ui/windows/AirWingDialog.py b/qt_ui/windows/AirWingDialog.py index 80c6443a..525f3c88 100644 --- a/qt_ui/windows/AirWingDialog.py +++ b/qt_ui/windows/AirWingDialog.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Optional, Type, Iterator +from typing import Optional, Iterator from PySide2.QtCore import ( QItemSelectionModel, @@ -20,9 +20,7 @@ from PySide2.QtWidgets import ( QTableWidgetItem, QWidget, ) -from dcs.unittype import FlyingType -from game import db from game.inventory import ControlPointAircraftInventory from game.squadrons import Squadron from gen.flights.flight import Flight @@ -45,9 +43,7 @@ class SquadronDelegate(TwoColumnRowDelegate): return self.air_wing_model.data(index, Qt.DisplayRole) elif (row, column) == (0, 1): squadron = self.air_wing_model.data(index, AirWingModel.SquadronRole) - return db.unit_get_expanded_info( - squadron.country, squadron.aircraft, "name" - ) + return squadron.aircraft.name elif (row, column) == (1, 0): return self.squadron(index).nickname elif (row, column) == (1, 1): @@ -111,7 +107,6 @@ class AircraftInventoryData: @classmethod def from_flight(cls, flight: Flight) -> Iterator[AircraftInventoryData]: - unit_type_name = cls.format_unit_type(flight.unit_type, flight.country) num_units = flight.count flight_type = flight.flight_type.value target = flight.package.target.name @@ -125,7 +120,7 @@ class AircraftInventoryData: player = "Player" if pilot.player else "AI" yield AircraftInventoryData( flight.departure.name, - unit_type_name, + flight.unit_type.name, flight_type, target, pilot_name, @@ -134,24 +129,19 @@ class AircraftInventoryData: @classmethod def each_from_inventory( - cls, inventory: ControlPointAircraftInventory, country: str + cls, inventory: ControlPointAircraftInventory ) -> Iterator[AircraftInventoryData]: for unit_type, num_units in inventory.all_aircraft: - unit_type_name = cls.format_unit_type(unit_type, country) for _ in range(0, num_units): yield AircraftInventoryData( inventory.control_point.name, - unit_type_name, + unit_type.name, "Idle", "N/A", "N/A", "N/A", ) - @staticmethod - def format_unit_type(aircraft: Type[FlyingType], country: str) -> str: - return db.unit_get_expanded_info(country, aircraft, "name") - class AirInventoryView(QWidget): def __init__(self, game_model: GameModel) -> None: @@ -201,9 +191,7 @@ class AirInventoryView(QWidget): 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, game.country_for(player=True) - ) + yield from AircraftInventoryData.each_from_inventory(inventory) def get_data(self, only_unallocated: bool) -> Iterator[AircraftInventoryData]: yield from self.iter_unallocated_aircraft() diff --git a/qt_ui/windows/QDebriefingWindow.py b/qt_ui/windows/QDebriefingWindow.py index 86e59e0d..f1635c3d 100644 --- a/qt_ui/windows/QDebriefingWindow.py +++ b/qt_ui/windows/QDebriefingWindow.py @@ -22,15 +22,7 @@ class LossGrid(QGridLayout): def __init__(self, debriefing: Debriefing, player: bool) -> None: super().__init__() - if player: - country = debriefing.player_country - else: - country = debriefing.enemy_country - - self.add_loss_rows( - debriefing.air_losses.by_type(player), - lambda u: db.unit_get_expanded_info(country, u, "name"), - ) + self.add_loss_rows(debriefing.air_losses.by_type(player), lambda u: u.name) self.add_loss_rows( debriefing.front_line_losses_by_type(player), lambda u: db.unit_type_name(u), diff --git a/qt_ui/windows/QUnitInfoWindow.py b/qt_ui/windows/QUnitInfoWindow.py index aaadd06d..0438b952 100644 --- a/qt_ui/windows/QUnitInfoWindow.py +++ b/qt_ui/windows/QUnitInfoWindow.py @@ -1,41 +1,40 @@ -import logging -from typing import Type +from typing import Type, Union -from PySide2 import QtCore +import dcs from PySide2.QtCore import Qt -from PySide2.QtGui import QIcon, QMovie, QPixmap +from PySide2.QtGui import QIcon from PySide2.QtWidgets import ( QDialog, QGridLayout, - QGroupBox, - QHBoxLayout, QLabel, - QMessageBox, - QPushButton, QTextBrowser, QFrame, ) -from jinja2 import Environment, FileSystemLoader, select_autoescape -from dcs.unittype import UnitType, FlyingType, VehicleType -import dcs -from qt_ui.uiconstants import AIRCRAFT_BANNERS, VEHICLE_BANNERS - -from game.game import Game -from game import db +from dcs.unittype import UnitType import gen.flights.ai_flight_planner_db +from game import db +from game.dcs.aircrafttype import AircraftType +from game.game import Game from gen.flights.flight import FlightType +from qt_ui.uiconstants import AIRCRAFT_BANNERS, VEHICLE_BANNERS class QUnitInfoWindow(QDialog): - def __init__(self, game: Game, unit_type: Type[UnitType]) -> None: - super(QUnitInfoWindow, self).__init__() + def __init__( + self, game: Game, unit_type: Union[AircraftType, Type[UnitType]] + ) -> None: + super().__init__() self.setModal(True) self.game = game self.unit_type = unit_type - self.setWindowTitle( - f"Unit Info: {db.unit_get_expanded_info(self.game.player_country, self.unit_type, 'name')}" - ) + if isinstance(unit_type, AircraftType): + self.name = unit_type.name + else: + self.name = db.unit_get_expanded_info( + self.game.player_country, self.unit_type, "name" + ) + self.setWindowTitle(f"Unit Info: {self.name}") self.setWindowIcon(QIcon("./resources/icon.png")) self.setMinimumHeight(570) self.setMaximumWidth(640) @@ -71,7 +70,7 @@ class QUnitInfoWindow(QDialog): self.details_grid_layout.setMargin(0) self.name_box = QLabel( - f"Name: {db.unit_get_expanded_info(self.game.player_country, self.unit_type, 'manufacturer')} {db.unit_get_expanded_info(self.game.player_country, self.unit_type, 'name')}" + f"Name: {db.unit_get_expanded_info(self.game.player_country, self.unit_type, 'manufacturer')} {self.name}" ) self.name_box.setProperty("style", "info-element") diff --git a/qt_ui/windows/basemenu/QRecruitBehaviour.py b/qt_ui/windows/basemenu/QRecruitBehaviour.py index a9209521..3973015a 100644 --- a/qt_ui/windows/basemenu/QRecruitBehaviour.py +++ b/qt_ui/windows/basemenu/QRecruitBehaviour.py @@ -1,5 +1,5 @@ import logging -from typing import Type +from typing import Type, Union from PySide2.QtWidgets import ( QGroupBox, @@ -10,9 +10,9 @@ from PySide2.QtWidgets import ( QSizePolicy, QSpacerItem, ) -from dcs.unittype import UnitType +from dcs.unittype import VehicleType -from game import db +from game.dcs.aircrafttype import AircraftType from game.theater import ControlPoint from game.unitdelivery import PendingUnitDeliveries from qt_ui.models import GameModel @@ -47,7 +47,7 @@ class QRecruitBehaviour: def add_purchase_row( self, - unit_type: Type[UnitType], + unit_type: Union[AircraftType, Type[VehicleType]], layout: QLayout, row: int, ) -> int: @@ -61,13 +61,7 @@ class QRecruitBehaviour: existing_units = self.cp.base.total_units_of_type(unit_type) scheduled_units = self.pending_deliveries.units.get(unit_type, 0) - unitName = QLabel( - "" - + db.unit_get_expanded_info( - self.game_model.game.player_country, unit_type, "name" - ) - + "" - ) + unitName = QLabel(f"{self.name_of(unit_type)}") unitName.setSizePolicy( QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) ) @@ -81,7 +75,7 @@ class QRecruitBehaviour: self.existing_units_labels[unit_type] = existing_units self.bought_amount_labels[unit_type] = amount_bought - price = QLabel("$ {:02d} m".format(db.PRICES[unit_type])) + price = QLabel(f"$ {self.price_of(unit_type)} M") price.setSizePolicy(QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)) buysell = QGroupBox() @@ -155,7 +149,7 @@ class QRecruitBehaviour: return row + 1 - def _update_count_label(self, unit_type: Type[UnitType]): + def _update_count_label(self, unit_type: Union[AircraftType, Type[VehicleType]]): self.bought_amount_labels[unit_type].setText( "{}".format( @@ -172,32 +166,31 @@ class QRecruitBehaviour: def update_available_budget(self) -> None: GameUpdateSignal.get_instance().updateBudget(self.game_model.game) - def buy(self, unit_type: Type[UnitType]): + def buy(self, unit_type: Union[AircraftType, Type[VehicleType]]): if not self.enable_purchase(unit_type): logging.error(f"Purchase of {unit_type.id} not allowed at {self.cp.name}") return - price = db.PRICES[unit_type] self.pending_deliveries.order({unit_type: 1}) - self.budget -= price + self.budget -= self.price_of(unit_type) self._update_count_label(unit_type) self.update_available_budget() def sell(self, unit_type): if self.pending_deliveries.available_next_turn(unit_type) > 0: - price = db.PRICES[unit_type] - self.budget += price + self.budget += self.price_of(unit_type) self.pending_deliveries.sell({unit_type: 1}) if self.pending_deliveries.units[unit_type] == 0: del self.pending_deliveries.units[unit_type] self._update_count_label(unit_type) self.update_available_budget() - def enable_purchase(self, unit_type: Type[UnitType]) -> bool: - price = db.PRICES[unit_type] - return self.budget >= price + def enable_purchase( + self, unit_type: Union[AircraftType, Type[VehicleType]] + ) -> bool: + return self.budget >= self.price_of(unit_type) - def enable_sale(self, unit_type: Type[UnitType]) -> bool: + def enable_sale(self, unit_type: Union[AircraftType, Type[VehicleType]]) -> bool: return True def info(self, unit_type): @@ -209,3 +202,9 @@ class QRecruitBehaviour: Set the maximum number of units that can be bought """ self.maximum_units = maximum_units + + def name_of(self, unit_type: Union[AircraftType, Type[VehicleType]]) -> str: + raise NotImplementedError + + def price_of(self, unit_type: Union[AircraftType, Type[VehicleType]]) -> int: + raise NotImplementedError diff --git a/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py b/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py index b5a6e7fe..4e12a131 100644 --- a/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py +++ b/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py @@ -1,5 +1,5 @@ import logging -from typing import Set, Type +from typing import Set from PySide2.QtCore import Qt from PySide2.QtWidgets import ( @@ -13,9 +13,8 @@ from PySide2.QtWidgets import ( QWidget, ) from dcs.helicopters import helicopter_map -from dcs.unittype import FlyingType, UnitType -from game import db +from game.dcs.aircrafttype import AircraftType from game.theater import ControlPoint, ControlPointType from qt_ui.models import GameModel from qt_ui.uiconstants import ICONS @@ -48,13 +47,11 @@ class QAircraftRecruitmentMenu(QFrame, QRecruitBehaviour): task_box_layout = QGridLayout() row = 0 - unit_types: Set[Type[FlyingType]] = set() + unit_types: Set[AircraftType] = set() for unit_type in self.game_model.game.player_faction.aircrafts: - if not issubclass(unit_type, FlyingType): - raise RuntimeError(f"Non-flying aircraft found in faction: {unit_type}") - if self.cp.is_carrier and unit_type not in db.CARRIER_CAPABLE: + if self.cp.is_carrier and not unit_type.carrier_capable: continue - if self.cp.is_lha and unit_type not in db.LHA_CAPABLE: + if self.cp.is_lha and not unit_type.lha_capable: continue if ( self.cp.cptype in [ControlPointType.FOB, ControlPointType.FARP] @@ -65,9 +62,7 @@ class QAircraftRecruitmentMenu(QFrame, QRecruitBehaviour): sorted_units = sorted( unit_types, - key=lambda u: db.unit_get_expanded_info( - self.game_model.game.player_country, u, "name" - ), + key=lambda u: u.name, ) for unit_type in sorted_units: row = self.add_purchase_row(unit_type, task_box_layout, row) @@ -85,30 +80,33 @@ class QAircraftRecruitmentMenu(QFrame, QRecruitBehaviour): main_layout.addWidget(scroll) self.setLayout(main_layout) - def enable_purchase(self, unit_type: Type[UnitType]) -> bool: + def enable_purchase(self, unit_type: AircraftType) -> bool: if not super().enable_purchase(unit_type): return False - if not issubclass(unit_type, FlyingType): - return False if not self.cp.can_operate(unit_type): return False return True - def enable_sale(self, unit_type: Type[UnitType]) -> bool: - if not issubclass(unit_type, FlyingType): - return False + 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): + def name_of(self, unit_type: AircraftType) -> str: + return unit_type.name + + def price_of(self, unit_type: AircraftType) -> int: + return unit_type.price + + def buy(self, unit_type: AircraftType) -> None: 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.", + f"There is no parking space left at {self.cp.name} to accommodate " + "another plane.", QMessageBox.Ok, ) return @@ -122,7 +120,7 @@ class QAircraftRecruitmentMenu(QFrame, QRecruitBehaviour): super().buy(unit_type) self.hangar_status.update_label() - def sell(self, unit_type: UnitType): + def sell(self, unit_type: AircraftType) -> None: # Don't need to remove aircraft from the inventory if we're canceling # orders. if self.pending_deliveries.units.get(unit_type, 0) <= 0: @@ -134,7 +132,7 @@ class QAircraftRecruitmentMenu(QFrame, QRecruitBehaviour): QMessageBox.critical( self, "Could not sell aircraft", - f"Attempted to sell one {unit_type.id} at {self.cp.name} " + 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, diff --git a/qt_ui/windows/basemenu/ground_forces/QArmorRecruitmentMenu.py b/qt_ui/windows/basemenu/ground_forces/QArmorRecruitmentMenu.py index 3f104e7d..63c80b6b 100644 --- a/qt_ui/windows/basemenu/ground_forces/QArmorRecruitmentMenu.py +++ b/qt_ui/windows/basemenu/ground_forces/QArmorRecruitmentMenu.py @@ -8,9 +8,10 @@ from PySide2.QtWidgets import ( QVBoxLayout, QWidget, ) -from dcs.unittype import UnitType +from dcs.unittype import UnitType, VehicleType from game import db +from game.db import PRICES from game.theater import ControlPoint from qt_ui.models import GameModel from qt_ui.windows.basemenu.QRecruitBehaviour import QRecruitBehaviour @@ -65,3 +66,11 @@ class QArmorRecruitmentMenu(QFrame, QRecruitBehaviour): def enable_sale(self, unit_type: Type[UnitType]) -> bool: return self.pending_deliveries.pending_orders(unit_type) > 0 + + def name_of(self, unit_type: Type[VehicleType]) -> str: + return db.unit_get_expanded_info( + self.game_model.game.player_country, unit_type, "name" + ) + + def price_of(self, unit_type: Type[VehicleType]) -> int: + return PRICES[unit_type] diff --git a/qt_ui/windows/basemenu/intel/QIntelInfo.py b/qt_ui/windows/basemenu/intel/QIntelInfo.py index cc9c3ca6..2f30d169 100644 --- a/qt_ui/windows/basemenu/intel/QIntelInfo.py +++ b/qt_ui/windows/basemenu/intel/QIntelInfo.py @@ -28,10 +28,8 @@ class QIntelInfo(QFrame): units_by_task: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int)) for unit_type, count in self.cp.base.aircraft.items(): if count: - name = db.unit_get_expanded_info( - self.game.enemy_country, unit_type, "name" - ) - units_by_task[unit_type.task_default.name][name] += count + task_type = unit_type.dcs_unit_type.task_default.name + units_by_task[task_type][unit_type.name] += count units_by_task = { task: units_by_task[task] for task in sorted(units_by_task.keys()) diff --git a/qt_ui/windows/intel.py b/qt_ui/windows/intel.py index 74203adf..6dd02915 100644 --- a/qt_ui/windows/intel.py +++ b/qt_ui/windows/intel.py @@ -84,10 +84,7 @@ class AircraftIntelLayout(IntelTableLayout): for airframe, count in base.aircraft.items(): if not count: continue - self.add_row( - db.unit_get_expanded_info(game.enemy_country, airframe, "name"), - count, - ) + self.add_row(airframe.name, count) self.add_spacer() self.add_row("Total", total) diff --git a/qt_ui/windows/mission/QFlightItem.py b/qt_ui/windows/mission/QFlightItem.py index d8d9cb44..42f1d47f 100644 --- a/qt_ui/windows/mission/QFlightItem.py +++ b/qt_ui/windows/mission/QFlightItem.py @@ -1,6 +1,5 @@ from PySide2.QtGui import QStandardItem, QIcon -from game import db from gen.ato import Package from gen.flights.flight import Flight from gen.flights.traveltime import TotEstimator @@ -14,11 +13,8 @@ class QFlightItem(QStandardItem): self.package = package self.flight = flight - if ( - db.unit_type_name(self.flight.unit_type).replace("/", " ") - in AIRCRAFT_ICONS.keys() - ): - icon = QIcon((AIRCRAFT_ICONS[db.unit_type_name(self.flight.unit_type)])) + if self.flight.unit_type.dcs_id in AIRCRAFT_ICONS: + icon = QIcon((AIRCRAFT_ICONS[self.flight.unit_type.dcs_id])) self.setIcon(icon) self.setEditable(False) estimator = TotEstimator(self.package) diff --git a/qt_ui/windows/mission/flight/settings/QFlightTypeTaskInfo.py b/qt_ui/windows/mission/flight/settings/QFlightTypeTaskInfo.py index c05a0fa6..32324676 100644 --- a/qt_ui/windows/mission/flight/settings/QFlightTypeTaskInfo.py +++ b/qt_ui/windows/mission/flight/settings/QFlightTypeTaskInfo.py @@ -1,6 +1,5 @@ -from PySide2.QtWidgets import QLabel, QHBoxLayout, QGroupBox, QSpinBox, QGridLayout +from PySide2.QtWidgets import QLabel, QGroupBox, QGridLayout -from game import db from qt_ui.uiconstants import AIRCRAFT_ICONS @@ -12,10 +11,8 @@ class QFlightTypeTaskInfo(QGroupBox): layout = QGridLayout() self.aircraft_icon = QLabel() - if db.unit_type_name(self.flight.unit_type) in AIRCRAFT_ICONS: - self.aircraft_icon.setPixmap( - AIRCRAFT_ICONS[db.unit_type_name(self.flight.unit_type)] - ) + if self.flight.unit_type.dcs_id in AIRCRAFT_ICONS: + self.aircraft_icon.setPixmap(AIRCRAFT_ICONS[self.flight.unit_type.dcs_id]) self.task = QLabel("Task:") self.task_type = QLabel(str(flight.flight_type)) diff --git a/resources/tools/convert_unit_data.py b/resources/tools/convert_unit_data.py new file mode 100644 index 00000000..8aae5779 --- /dev/null +++ b/resources/tools/convert_unit_data.py @@ -0,0 +1,512 @@ +from __future__ import annotations + +import json +from collections import defaultdict +from dataclasses import dataclass +from pathlib import Path +from typing import Any +from typing import Optional, Type + +import dcs +import yaml +from dcs.helicopters import ( + AH_1W, + AH_64A, + AH_64D, + Ka_50, + Mi_24V, + Mi_28N, + Mi_8MT, + OH_58D, + SA342L, + SA342M, + SA342Minigun, + SA342Mistral, + SH_60B, + UH_1H, + UH_60A, + helicopter_map, +) + +from dcs.planes import ( + AV8BNA, + A_10A, + A_10C, + A_10C_2, + A_20G, + Bf_109K_4, + E_2C, + FA_18C_hornet, + FW_190A8, + FW_190D9, + F_14A_135_GR, + F_14B, + F_86F_Sabre, + Ju_88A4, + MiG_15bis, + MiG_19P, + P_47D_30, + P_47D_30bl1, + P_47D_40, + P_51D, + P_51D_30_NA, + S_3B, + S_3B_Tanker, + SpitfireLFMkIX, + SpitfireLFMkIXCW, + Su_25, + Su_25T, + Su_33, + plane_map, +) +from dcs.unittype import FlyingType + +from game.db import PRICES +from game.factions.faction import unit_loader +from game.radio.channels import ( + RadioChannelAllocator, + ChannelNamer, + NoOpChannelAllocator, + ViggenRadioChannelAllocator, + ViggenChannelNamer, + CommonRadioChannelAllocator, + TomcatChannelNamer, + ViperChannelNamer, + MirageChannelNamer, + FarmerRadioChannelAllocator, + SingleRadioChannelNamer, + SCR522ChannelNamer, + HueyChannelNamer, +) +from gen.radios import get_radio, Radio +from pydcs_extensions.a4ec.a4ec import A_4E_C +from pydcs_extensions.mod_units import MODDED_AIRPLANES + +THIS_DIR = Path(__file__).resolve().parent +SRC_ROOT = THIS_DIR.parent.parent +UNIT_DATA_DIR = SRC_ROOT / "resources/units" +FACTIONS_DIR = SRC_ROOT / "resources/factions" + + +# List of airframes that rely on their gun as a primary weapon. We confiscate bullets +# from most AI air-to-ground missions since they aren't smart enough to RTB when they're +# out of everything other than bullets (DCS does not have an all-but-gun winchester +# option) and we don't want to be attacking fully functional Tors with a Vulcan. +# +# These airframes are the exceptions. They probably should be using their gun regardless +# of the mission type. +GUN_RELIANT_AIRFRAMES: list[Type[FlyingType]] = [ + AH_1W, + AH_64A, + AH_64D, + A_10A, + A_10C, + A_10C_2, + A_20G, + Bf_109K_4, + FW_190A8, + FW_190D9, + F_86F_Sabre, + Ju_88A4, + Ka_50, + MiG_15bis, + MiG_19P, + Mi_24V, + Mi_28N, + P_47D_30, + P_47D_30bl1, + P_47D_40, + P_51D, + P_51D_30_NA, + SpitfireLFMkIX, + SpitfireLFMkIXCW, + Su_25, + Su_25T, +] + +CARRIER_CAPABLE = [ + FA_18C_hornet, + F_14A_135_GR, + F_14B, + AV8BNA, + Su_33, + A_4E_C, + S_3B, + S_3B_Tanker, + E_2C, + UH_1H, + Mi_8MT, + Ka_50, + AH_1W, + OH_58D, + UH_60A, + SH_60B, + SA342L, + SA342M, + SA342Minigun, + SA342Mistral, +] + +LHA_CAPABLE = [ + AV8BNA, + UH_1H, + Mi_8MT, + Ka_50, + AH_1W, + OH_58D, + UH_60A, + SH_60B, + SA342L, + SA342M, + SA342Minigun, + SA342Mistral, +] + + +@dataclass(frozen=True) +class AircraftData: + """Additional aircraft data not exposed by pydcs.""" + + #: The type of radio used for inter-flight communications. + inter_flight_radio: Radio + + #: The type of radio used for intra-flight communications. + intra_flight_radio: Radio + + #: The radio preset channel allocator, if the aircraft supports channel + #: presets. If the aircraft does not support preset channels, this will be + #: None. + channel_allocator: Optional[RadioChannelAllocator] + + #: Defines how channels should be named when printed in the kneeboard. + channel_namer: Type[ChannelNamer] = ChannelNamer + + +# Indexed by the id field of the pydcs PlaneType. +AIRCRAFT_DATA: dict[str, AircraftData] = { + "A-10C": AircraftData( + inter_flight_radio=get_radio("AN/ARC-164"), + # VHF for intraflight is not accepted anymore by DCS + # (see https://forums.eagle.ru/showthread.php?p=4499738). + intra_flight_radio=get_radio("AN/ARC-164"), + channel_allocator=NoOpChannelAllocator(), + ), + "AJS37": AircraftData( + # The AJS37 has somewhat unique radio configuration. Two backup radio + # (FR 24) can only operate simultaneously with the main radio in guard + # mode. As such, we only use the main radio for both inter- and intra- + # flight communication. + inter_flight_radio=get_radio("FR 22"), + intra_flight_radio=get_radio("FR 22"), + channel_allocator=ViggenRadioChannelAllocator(), + channel_namer=ViggenChannelNamer, + ), + "AV8BNA": AircraftData( + inter_flight_radio=get_radio("AN/ARC-210"), + intra_flight_radio=get_radio("AN/ARC-210"), + channel_allocator=CommonRadioChannelAllocator( + inter_flight_radio_index=2, intra_flight_radio_index=1 + ), + ), + "F-14B": AircraftData( + inter_flight_radio=get_radio("AN/ARC-159"), + intra_flight_radio=get_radio("AN/ARC-182"), + channel_allocator=CommonRadioChannelAllocator( + inter_flight_radio_index=1, intra_flight_radio_index=2 + ), + channel_namer=TomcatChannelNamer, + ), + "F-16C_50": AircraftData( + inter_flight_radio=get_radio("AN/ARC-164"), + intra_flight_radio=get_radio("AN/ARC-222"), + # COM2 is the AN/ARC-222, which is the VHF radio we want to use for + # intra-flight communication to leave COM1 open for UHF inter-flight. + channel_allocator=CommonRadioChannelAllocator( + inter_flight_radio_index=1, intra_flight_radio_index=2 + ), + channel_namer=ViperChannelNamer, + ), + "JF-17": AircraftData( + inter_flight_radio=get_radio("R&S M3AR UHF"), + intra_flight_radio=get_radio("R&S M3AR VHF"), + channel_allocator=CommonRadioChannelAllocator( + inter_flight_radio_index=1, intra_flight_radio_index=1 + ), + # Same naming pattern as the Viper, so just reuse that. + channel_namer=ViperChannelNamer, + ), + "Ka-50": AircraftData( + inter_flight_radio=get_radio("R-800L1"), + intra_flight_radio=get_radio("R-800L1"), + # The R-800L1 doesn't have preset channels, and the other radio is for + # communications with FAC and ground units, which don't currently have + # radios assigned, so no channels to configure. + channel_allocator=NoOpChannelAllocator(), + ), + "M-2000C": AircraftData( + inter_flight_radio=get_radio("TRT ERA 7000 V/UHF"), + intra_flight_radio=get_radio("TRT ERA 7200 UHF"), + channel_allocator=CommonRadioChannelAllocator( + inter_flight_radio_index=1, intra_flight_radio_index=2 + ), + channel_namer=MirageChannelNamer, + ), + "MiG-15bis": AircraftData( + inter_flight_radio=get_radio("RSI-6K HF"), + intra_flight_radio=get_radio("RSI-6K HF"), + channel_allocator=NoOpChannelAllocator(), + ), + "MiG-19P": AircraftData( + inter_flight_radio=get_radio("RSIU-4V"), + intra_flight_radio=get_radio("RSIU-4V"), + channel_allocator=FarmerRadioChannelAllocator(), + channel_namer=SingleRadioChannelNamer, + ), + "MiG-21Bis": AircraftData( + inter_flight_radio=get_radio("RSIU-5V"), + intra_flight_radio=get_radio("RSIU-5V"), + channel_allocator=CommonRadioChannelAllocator( + inter_flight_radio_index=1, intra_flight_radio_index=1 + ), + channel_namer=SingleRadioChannelNamer, + ), + "P-51D": AircraftData( + inter_flight_radio=get_radio("SCR522"), + intra_flight_radio=get_radio("SCR522"), + channel_allocator=CommonRadioChannelAllocator( + inter_flight_radio_index=1, intra_flight_radio_index=1 + ), + channel_namer=SCR522ChannelNamer, + ), + "UH-1H": AircraftData( + inter_flight_radio=get_radio("AN/ARC-51BX"), + # Ideally this would use the AN/ARC-131 because that radio is supposed + # to be used for flight comms, but DCS won't allow it as the flight's + # frequency, nor will it allow the AN/ARC-134. + intra_flight_radio=get_radio("AN/ARC-51BX"), + channel_allocator=CommonRadioChannelAllocator( + inter_flight_radio_index=1, intra_flight_radio_index=1 + ), + channel_namer=HueyChannelNamer, + ), + "F-22A": AircraftData( + inter_flight_radio=get_radio("SCR-522"), + intra_flight_radio=get_radio("SCR-522"), + channel_allocator=None, + channel_namer=SCR522ChannelNamer, + ), + "JAS39Gripen": AircraftData( + inter_flight_radio=get_radio("R&S Series 6000"), + intra_flight_radio=get_radio("R&S Series 6000"), + channel_allocator=None, + ), +} +AIRCRAFT_DATA["A-10C_2"] = AIRCRAFT_DATA["A-10C"] +AIRCRAFT_DATA["P-51D-30-NA"] = AIRCRAFT_DATA["P-51D"] +AIRCRAFT_DATA["P-47D-30"] = AIRCRAFT_DATA["P-51D"] +AIRCRAFT_DATA["JAS39Gripen_AG"] = AIRCRAFT_DATA["JAS39Gripen"] + + +class Converter: + def __init__(self) -> None: + self.all_variants: set[str] = set() + self.variant_map: dict[str, dict[str, str]] = {} + self.unconverted: set[Type[FlyingType]] = set( + k for k in PRICES if issubclass(k, FlyingType) + ) + + @staticmethod + def find_unit_id_for_faction_name(name: str) -> str: + unit_type = unit_loader(name, [dcs.planes, dcs.helicopters, MODDED_AIRPLANES]) + if unit_type is None: + raise KeyError(f"Found no unit named {name}") + return unit_type.id + + def convert(self) -> None: + data_path = UNIT_DATA_DIR / "unit_info_text.json" + with data_path.open(encoding="utf-8") as unit_data_file: + unit_data = json.load(unit_data_file) + + for unit_name, data in dict(unit_data).items(): + if self.convert_unit(unit_name, data): + unit_data.pop(unit_name) + + with data_path.open("w", encoding="utf-8") as unit_data_file: + json.dump(unit_data, unit_data_file, indent=2) + + for unconverted in self.unconverted: + self.generate_basic_info(unconverted) + + for faction_path in FACTIONS_DIR.glob("*.json"): + self.update_faction(faction_path) + + def update_faction(self, faction_path: Path) -> None: + with faction_path.open() as faction_file: + data = json.load(faction_file) + + self.update_aircraft_list(data, "aircrafts") + self.update_aircraft_list(data, "awacs") + self.update_aircraft_list(data, "tankers") + self.update_aircraft_item(data, "jtac_unit") + + if "liveries_overrides" in data: + new_liveries = {} + for aircraft, liveries in data["liveries_overrides"].items(): + name = self.new_name_for(aircraft, data["country"]) + new_liveries[name] = sorted(liveries) + data["liveries_overrides"] = new_liveries + + with faction_path.open("w") as faction_file: + json.dump(data, faction_file, indent=2) + + def new_name_for(self, old_name: str, country: str) -> str: + if old_name in self.all_variants: + return old_name + aircraft_id = self.find_unit_id_for_faction_name(old_name) + return self.variant_map[aircraft_id][country] + + def update_aircraft_list(self, data: dict[str, Any], field: str) -> None: + if field not in data: + return + + new_aircraft = [] + for aircraft in data[field]: + new_aircraft.append(self.new_name_for(aircraft, data["country"])) + data[field] = sorted(new_aircraft) + + def update_aircraft_item(self, data: dict[str, Any], field: str) -> None: + if field in data: + aircraft_name = data[field] + data[field] = self.new_name_for(aircraft_name, data["country"]) + + def generate_basic_info(self, unit_type: Type[FlyingType]) -> None: + self.all_variants.add(unit_type.id) + output_path = UNIT_DATA_DIR / "aircraft" / f"{unit_type.id}.yaml" + if output_path.exists(): + # Already have data for this, don't clobber it, but do register the + # variant names. + with output_path.open() as unit_info_file: + data = yaml.safe_load(unit_info_file) + self.all_variants.update(data["variants"].keys()) + return + with output_path.open("w") as output_file: + yaml.safe_dump( + { + "price": PRICES[unit_type], + "variants": {unit_type.id: None}, + }, + output_file, + ) + + self.variant_map[unit_type.id] = defaultdict(lambda: unit_type.id) + + def convert_unit( + self, pydcs_name: str, data: list[dict[str, dict[str, str]]] + ) -> bool: + if len(data) != 1: + raise ValueError(f"Unexpected data format for {pydcs_name}") + + unit_type: Type[FlyingType] + if pydcs_name in plane_map: + unit_type = plane_map[pydcs_name] + elif pydcs_name in helicopter_map: + unit_type = helicopter_map[pydcs_name] + else: + return False + + self.unconverted.remove(unit_type) + + variants_dict = data[0] + default = variants_dict.pop("default") + + default_name = default["name"] + self.all_variants.add(default_name) + country_to_variant = defaultdict(lambda: default_name) + + variants = {default_name: {}} + for country, variant_dict in variants_dict.items(): + variant_name = variant_dict["name"] + self.all_variants.add(variant_name) + country_to_variant[country] = variant_name + variants[variant_name] = self.get_variant_data(variant_dict) + + output_dict: dict[str, Any] = {"variants": variants, "price": PRICES[unit_type]} + output_dict.update(self.get_variant_data(default)) + + if unit_type in CARRIER_CAPABLE: + output_dict["carrier_capable"] = True + if unit_type in LHA_CAPABLE: + output_dict["lha_capable"] = True + if unit_type in GUN_RELIANT_AIRFRAMES: + output_dict["always_keeps_gun"] = True + + try: + aircraft_data = AIRCRAFT_DATA[unit_type.id] + radio_dict: dict[str, Any] = { + "intra_flight": aircraft_data.intra_flight_radio.name, + "inter_flight": aircraft_data.inter_flight_radio.name, + } + channels_dict: dict[str, Any] = {} + if type(aircraft_data.channel_namer) != ChannelNamer: + channels_dict["namer"] = aircraft_data.channel_namer.name() + if aircraft_data.channel_allocator is not None: + alloc = aircraft_data.channel_allocator + if alloc.name() != "noop": + channels_dict["type"] = alloc.name() + if isinstance(alloc, CommonRadioChannelAllocator): + channels_dict[ + "intra_flight_radio_index" + ] = alloc.intra_flight_radio_index + channels_dict[ + "inter_flight_radio_index" + ] = alloc.inter_flight_radio_index + if channels_dict: + radio_dict["channels"] = channels_dict + except KeyError: + pass + + output_path = UNIT_DATA_DIR / "aircraft" / f"{unit_type.id}.yaml" + output_path.parent.mkdir(parents=True, exist_ok=True) + with output_path.open("w") as output_file: + yaml.safe_dump(output_dict, output_file) + + self.variant_map[pydcs_name] = country_to_variant + return True + + @staticmethod + def get_variant_data(variant: dict[str, Any]) -> dict[str, Any]: + result = {} + + try: + result["manufacturer"] = variant["manufacturer"] + except KeyError: + pass + + try: + result["origin"] = variant["country-of-origin"] + except KeyError: + pass + try: + result["role"] = variant["role"] + except KeyError: + pass + + try: + as_str = variant["year-of-variant-introduction"] + if as_str == "N/A": + result["introduced"] = None + else: + result["introduced"] = int(as_str) + except KeyError: + pass + + try: + result["description"] = variant["text"] + except KeyError: + pass + + return result + + +if __name__ == "__main__": + Converter().convert() diff --git a/resources/units/FA-18C_hornet.yaml b/resources/units/FA-18C_hornet.yaml new file mode 100644 index 00000000..851d1837 --- /dev/null +++ b/resources/units/FA-18C_hornet.yaml @@ -0,0 +1,38 @@ +--- +variants: + - F/A-18C Hornet (Lot 20) + - CF-188 Hornet + - EF-18A+ Hornet +price: 22 +carrier_capable: true +radios: + intra_flight: AN/ARC-210 + inter_flight: AN/ARC-210 + channels: + type: common + # DCS will clobber channel 1 of the first radio compatible with the flight's + # assigned frequency. Since the F/A-18's two radios are both AN/ARC-210s, + # radio 1 will be compatible regardless of which frequency is assigned, so + # we must use radio 1 for the intra-flight radio. + intra_flight_radio_index: 1 + inter_flight_radio_index: 2 +manufacturer: McDonnell Douglass +role: Carrier-based Multirole Fighter +origin: USA +introduced: 1987 +description: >- + The F/A-18C Hornet is twin engine, supersonic fighter that is flown by a + single pilot in a "glass cockpit". It combines extreme maneuverability , a + deadly arsenal of weapons, and the ability to operate from an aircraft + carrier. Operated by several nations, this multi-role fighter has been + instrumental in conflicts from 1986 to today. + + The Hornet is equipped with a large suite of sensors that includes a radar, + targeting pod, and a helmet mounted sight. In addition to its internal 20mm + cannon, the Hornet can be armed with a large assortment of unguided bombs and + rockets, laser and GPS-guided bombs, air-to-surface missiles of all sorts, and + both radar and infrared-guided air-to-air missiles. + + The Hornet is also known for its extreme, slow-speed maneuverability in a + dogfight. Although incredibly deadly, the Hornet is also a very easy aircraft + to fly.