diff --git a/changelog.md b/changelog.md index 248b6112..c2ee23ae 100644 --- a/changelog.md +++ b/changelog.md @@ -4,6 +4,8 @@ Saves from 2.4 are not compatible with 2.5. ## Features/Improvements +* **[Flight Planner]** (WIP) Added AEW&C missions. + ## Fixes # 2.4.1 diff --git a/game/db.py b/game/db.py index 08260e15..252112e8 100644 --- a/game/db.py +++ b/game/db.py @@ -1117,7 +1117,8 @@ COMMON_OVERRIDE = { GroundAttack: "STRIKE", Escort: "CAP", RunwayAttack: "RUNWAY_ATTACK", - FighterSweep: "CAP" + FighterSweep: "CAP", + AWACS: "AEW&C", } """ @@ -1328,6 +1329,7 @@ CARRIER_CAPABLE = [ A_4E_C, Rafale_M, S_3B, + E_2C, UH_1H, Mi_8MT, diff --git a/game/theater/base.py b/game/theater/base.py index 28fd5666..65c822c4 100644 --- a/game/theater/base.py +++ b/game/theater/base.py @@ -4,7 +4,7 @@ import math import typing from typing import Dict, Type -from dcs.task import CAP, CAS, Embarking, PinpointStrike, Task +from dcs.task import AWACS, CAP, CAS, Embarking, PinpointStrike, Task from dcs.unittype import FlyingType, UnitType, VehicleType from dcs.vehicles import AirDefence, Armor @@ -122,7 +122,7 @@ class Base: for_task = db.unit_task(unit_type) target_dict = None - if for_task == CAS or for_task == CAP or for_task == Embarking: + if for_task == AWACS or for_task == CAS or for_task == CAP or for_task == Embarking: target_dict = self.aircraft elif for_task == PinpointStrike: target_dict = self.armor diff --git a/game/theater/conflicttheater.py b/game/theater/conflicttheater.py index 673a7c36..b21b9d16 100644 --- a/game/theater/conflicttheater.py +++ b/game/theater/conflicttheater.py @@ -812,6 +812,7 @@ class FrontLine(MissionTarget): def mission_types(self, for_player: bool) -> Iterator[FlightType]: yield from [ FlightType.CAS, + FlightType.AEWC, # TODO: FlightType.TROOP_TRANSPORT # TODO: FlightType.EVAC ] diff --git a/game/theater/controlpoint.py b/game/theater/controlpoint.py index e406e1ac..e7017b94 100644 --- a/game/theater/controlpoint.py +++ b/game/theater/controlpoint.py @@ -603,6 +603,14 @@ class ControlPoint(MissionTarget, ABC): def income_per_turn(self) -> int: return 0 + def mission_types(self, for_player: bool) -> Iterator[FlightType]: + from gen.flights.flight import FlightType + if self.is_friendly(for_player): + yield from [ + FlightType.AEWC, + ] + yield from super().mission_types(for_player) + class Airfield(ControlPoint): diff --git a/gen/aircraft.py b/gen/aircraft.py index 70ab9d67..044b6eff 100644 --- a/gen/aircraft.py +++ b/gen/aircraft.py @@ -41,6 +41,7 @@ from dcs.planes import ( ) from dcs.point import MovingPoint, PointAction from dcs.task import ( + AWACS, AntishipStrike, AttackGroup, Bombing, @@ -90,7 +91,6 @@ from game.utils import Distance, meters, nautical_miles from gen.airsupportgen import AirSupport from gen.ato import AirTaskingOrder, Package from gen.callsigns import create_group_callsign_from_unit -from gen.conflictgen import FRONTLINE_LENGTH from gen.flights.flight import ( Flight, FlightType, @@ -129,10 +129,11 @@ HELICOPTER_CHANNEL = MHz(127) UHF_FALLBACK_CHANNEL = MHz(251) TARGET_WAYPOINTS = ( - FlightWaypointType.TARGET_GROUP_LOC, - FlightWaypointType.TARGET_POINT, - FlightWaypointType.TARGET_SHIP, - ) + FlightWaypointType.TARGET_GROUP_LOC, + FlightWaypointType.TARGET_POINT, + FlightWaypointType.TARGET_SHIP, +) + # TODO: Get radio information for all the special cases. def get_fallback_channel(unit_type: UnitType) -> RadioFrequency: @@ -731,11 +732,14 @@ class AircraftConflictGenerator: group.load_loadout(payload_name) if not group.units[0].pylons and for_task == RunwayAttack: if PinpointStrike in db.PLANE_PAYLOAD_OVERRIDES[unit_type]: - logging.warning("No loadout for \"Runway Attack\" for the {}, defaulting to Strike loadout".format(str(unit_type))) + logging.warning( + "No loadout for \"Runway Attack\" for the {}, defaulting to Strike loadout".format( + str(unit_type))) payload_name = db.PLANE_PAYLOAD_OVERRIDES[unit_type][PinpointStrike] group.load_loadout(payload_name) did_load_loadout = True - logging.info("Loaded overridden payload for {} - {} for task {}".format(unit_type, payload_name, for_task)) + logging.info( + "Loaded overridden payload for {} - {} for task {}".format(unit_type, payload_name, for_task)) if not did_load_loadout: group.load_task_default_loadout(for_task) @@ -995,7 +999,7 @@ class AircraftConflictGenerator: group = self._generate_at_airport( name=namegen.next_aircraft_name(country, control_point.id, - flight), + flight), side=country, unit_type=aircraft, count=1, @@ -1058,7 +1062,7 @@ class AircraftConflictGenerator: trigger.add_condition( CoalitionHasAirdrome(coalition, flight.from_cp.id)) - def generate_planned_flight(self, cp, country, flight:Flight): + def generate_planned_flight(self, cp, country, flight: Flight): name = namegen.next_aircraft_name(country, cp.id, flight) try: if flight.start_type == "In Flight": @@ -1249,6 +1253,17 @@ class AircraftConflictGenerator: roe=OptROE.Values.OpenFire, restrict_jettison=True) + def configure_awacs( + self, group: FlyingGroup, package: Package, flight: Flight, + dynamic_runways: Dict[str, RunwayData]) -> None: + group.task = AWACS.name + self._setup_group(group, AWACS, package, flight, dynamic_runways) + self.configure_behavior( + group, + react_on_threat=OptReactOnThreat.Values.EvadeFire, + roe=OptROE.Values.WeaponHold, + restrict_jettison=True) + def configure_escort(self, group: FlyingGroup, package: Package, flight: Flight, dynamic_runways: Dict[str, RunwayData]) -> None: @@ -1274,6 +1289,8 @@ class AircraftConflictGenerator: self.configure_cap(group, package, flight, dynamic_runways) elif flight_type == FlightType.SWEEP: self.configure_sweep(group, package, flight, dynamic_runways) + elif flight_type == FlightType.AEWC: + self.configure_awacs(group, package, flight, dynamic_runways) elif flight_type in [FlightType.CAS, FlightType.BAI]: self.configure_cas(group, package, flight, dynamic_runways) elif flight_type == FlightType.DEAD: @@ -1326,8 +1343,8 @@ class AircraftConflictGenerator: filtered_points = [ point for idx, point in enumerate(filtered_points) if ( point.waypoint_type not in TARGET_WAYPOINTS or idx == keep_target[0] - ) - ] + ) + ] for idx, point in enumerate(filtered_points): PydcsWaypointBuilder.for_waypoint( @@ -1458,8 +1475,8 @@ class PydcsWaypointBuilder: If the flight is a player controlled Viggen flight, no TOT should be set on any waypoint except actual target waypoints. """ if ( - (self.flight.client_count > 0 and self.flight.unit_type == AJS37) and - (self.waypoint.waypoint_type not in TARGET_WAYPOINTS) + (self.flight.client_count > 0 and self.flight.unit_type == AJS37) and + (self.waypoint.waypoint_type not in TARGET_WAYPOINTS) ): return True else: @@ -1622,12 +1639,12 @@ class SeadIngressBuilder(PydcsWaypointBuilder): tgroup = self.mission.find_group(target_group.group_name) if tgroup is not None: waypoint.add_task(EngageTargetsInZone( - position=tgroup.position, - radius=int(nautical_miles(30).meters), - targets=[ - Targets.All.GroundUnits.AirDefence, - ]) - ) + position=tgroup.position, + radius=int(nautical_miles(30).meters), + targets=[ + Targets.All.GroundUnits.AirDefence, + ]) + ) else: logging.error(f"Could not find group for DEAD mission {target_group.group_name}") self.register_special_waypoints(self.waypoint.targets) diff --git a/gen/ato.py b/gen/ato.py index 99a76789..b1de7b11 100644 --- a/gen/ato.py +++ b/gen/ato.py @@ -174,6 +174,7 @@ class Package: FlightType.TARCAP, FlightType.BARCAP, FlightType.SWEEP, + FlightType.AEWC, FlightType.ESCORT, ] for task in task_priorities: diff --git a/gen/flights/ai_flight_planner_db.py b/gen/flights/ai_flight_planner_db.py index 99782aba..a29ac6cd 100644 --- a/gen/flights/ai_flight_planner_db.py +++ b/gen/flights/ai_flight_planner_db.py @@ -12,8 +12,8 @@ from dcs.helicopters import ( OH_58D, SA342L, SA342M, + SH_60B, UH_1H, - SH_60B ) from dcs.planes import ( AJS37, @@ -22,11 +22,14 @@ from dcs.planes import ( A_10C, A_10C_2, A_20G, + A_50, B_17G, B_1B, B_52H, Bf_109K_4, C_101CC, + E_2C, + E_3A, FA_18C_hornet, FW_190A8, FW_190D9, @@ -40,9 +43,11 @@ from dcs.planes import ( F_4E, F_5E_3, F_86F_Sabre, + I_16, JF_17, J_11A, Ju_88A4, + KJ_2000, L_39ZA, MQ_9_Reaper, M_2000C, @@ -83,18 +88,16 @@ from dcs.planes import ( Tu_22M3, Tu_95MS, WingLoong_I, - I_16 ) from dcs.unittype import FlyingType from gen.flights.flight import FlightType - from pydcs_extensions.a4ec.a4ec import A_4E_C from pydcs_extensions.f22a.f22a import F_22A -from pydcs_extensions.mb339.mb339 import MB_339PAN -from pydcs_extensions.rafale.rafale import Rafale_A_S, Rafale_M, Rafale_B -from pydcs_extensions.su57.su57 import Su_57 from pydcs_extensions.hercules.hercules import Hercules +from pydcs_extensions.mb339.mb339 import MB_339PAN +from pydcs_extensions.rafale.rafale import Rafale_A_S, Rafale_B, Rafale_M +from pydcs_extensions.su57.su57 import Su_57 # All aircraft lists are in priority order. Aircraft higher in the list will be # preferred over those lower in the list. @@ -155,8 +158,8 @@ CAP_CAPABLE = [ # Used for CAS (Close air support) and BAI (Battlefield Interdiction) CAS_CAPABLE = [ A_10C_2, - A_10C, B_1B, + A_10C, F_14B, F_14A_135_GR, Su_25TM, @@ -373,6 +376,13 @@ DRONES = [ WingLoong_I ] +AEWC_CAPABLE = [ + E_3A, + E_2C, + A_50, + KJ_2000, +] + def aircraft_for_task(task: FlightType) -> List[Type[FlyingType]]: cap_missions = (FlightType.BARCAP, FlightType.TARCAP) @@ -396,6 +406,8 @@ def aircraft_for_task(task: FlightType) -> List[Type[FlyingType]]: return STRIKE_CAPABLE elif task == FlightType.ESCORT: return CAP_CAPABLE + elif task == FlightType.AEWC: + return AEWC_CAPABLE else: logging.error(f"Unplannable flight type: {task}") return [] diff --git a/gen/flights/flight.py b/gen/flights/flight.py index 06ed2c8b..3cae97df 100644 --- a/gen/flights/flight.py +++ b/gen/flights/flight.py @@ -20,6 +20,14 @@ if TYPE_CHECKING: class FlightType(Enum): + """Enumeration of mission types. + + The value of each enumeration is the name that will be shown in the UI. + + These values are persisted to the save game as well since they are a part of + each flight and thus a part of the ATO, so changing these values will break + save compat. + """ TARCAP = "TARCAP" BARCAP = "BARCAP" CAS = "CAS" @@ -33,6 +41,7 @@ class FlightType(Enum): SWEEP = "Fighter sweep" OCA_RUNWAY = "OCA/Runway" OCA_AIRCRAFT = "OCA/Aircraft" + AEWC = "AEW&C" def __str__(self) -> str: return self.value diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py index bbf45b1f..743eeaaa 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -15,6 +15,13 @@ from datetime import timedelta from functools import cached_property from typing import Iterator, List, Optional, Set, TYPE_CHECKING, Tuple +from dcs.planes import ( + E_3A, + E_2C, + A_50, + KJ_2000 +) + from dcs.mapping import Point from dcs.unit import Unit from shapely.geometry import Point as ShapelyPoint @@ -29,7 +36,7 @@ from game.theater import ( TheaterGroundObject, ) from game.theater.theatergroundobject import EwrGroundObject -from game.utils import Distance, Speed, meters, nautical_miles +from game.utils import Distance, Speed, feet, meters, nautical_miles from .closestairfields import ObjectiveDistanceCache from .flight import Flight, FlightType, FlightWaypoint, FlightWaypointType from .traveltime import GroundSpeed, TravelTime @@ -121,7 +128,7 @@ class FlightPlan: failed to generate. Nevertheless, we have to defend against it. """ raise NotImplementedError - + @cached_property def bingo_fuel(self) -> int: """Bingo fuel value for the FlightPlan @@ -145,7 +152,7 @@ class FlightPlan: """Joker fuel value for the FlightPlan """ return self.bingo_fuel + 1000 - + def max_distance_from(self, cp: ControlPoint) -> Distance: """Returns the farthest waypoint of the flight plan from a ControlPoint. :arg cp The ControlPoint to measure distance from. @@ -280,11 +287,11 @@ class LoiterFlightPlan(FlightPlan): travel_time = super().travel_time_between_waypoints(a, b) if a != self.hold: return travel_time - try: - return travel_time + self.hold_duration - except AttributeError: - # Save compat for 2.3. - return travel_time + timedelta(minutes=5) + return travel_time + self.hold_duration + + @property + def mission_departure_time(self) -> timedelta: + raise NotImplementedError @dataclass(frozen=True) @@ -542,10 +549,10 @@ class StrikeFlightPlan(FormationFlightPlan): @property def package_speed_waypoints(self) -> Set[FlightWaypoint]: return { - self.ingress, - self.egress, - self.split, - } | set(self.targets) + self.ingress, + self.egress, + self.split, + } | set(self.targets) def speed_between_waypoints(self, a: FlightWaypoint, b: FlightWaypoint) -> Speed: @@ -696,6 +703,41 @@ class SweepFlightPlan(LoiterFlightPlan): return self.sweep_end_time +@dataclass(frozen=True) +class AwacsFlightPlan(LoiterFlightPlan): + takeoff: FlightWaypoint + nav_to: List[FlightWaypoint] + nav_from: List[FlightWaypoint] + land: FlightWaypoint + divert: Optional[FlightWaypoint] + + def iter_waypoints(self) -> Iterator[FlightWaypoint]: + yield self.takeoff + yield from self.nav_to + yield self.hold + yield from self.nav_from + yield self.land + if self.divert is not None: + yield self.divert + + def tot_for_waypoint(self, waypoint: FlightWaypoint) -> Optional[timedelta]: + if waypoint == self.hold: + return self.package.time_over_target + return None + + @property + def tot_waypoint(self) -> Optional[FlightWaypoint]: + return self.hold + + @property + def push_time(self) -> timedelta: + return self.package.time_over_target + self.hold_duration + + @property + def mission_departure_time(self) -> timedelta: + return self.push_time + + @dataclass(frozen=True) class CustomFlightPlan(FlightPlan): custom_waypoints: List[FlightWaypoint] @@ -791,6 +833,8 @@ class FlightPlanBuilder: return self.generate_sweep(flight) elif task == FlightType.TARCAP: return self.generate_tarcap(flight) + elif task == FlightType.AEWC: + return self.generate_aewc(flight) raise PlanningError( f"{task} flight plan generation not implemented") @@ -921,6 +965,45 @@ class FlightPlanBuilder: FlightWaypointType.INGRESS_STRIKE, targets) + def generate_aewc(self, flight: Flight) -> AwacsFlightPlan: + """Generate a AWACS flight at a given location. + + Args: + flight: The flight to generate the flight plan for. + """ + location = self.package.target + + start = self.aewc_orbit(location) + + # As high as possible to maximize detection and on-station time. + if flight.unit_type == E_2C: + patrol_alt = feet(30000) + elif flight.unit_type == E_3A: + patrol_alt = feet(35000) + elif flight.unit_type == A_50: + patrol_alt = feet(33000) + elif flight.unit_type == KJ_2000: + patrol_alt = feet(40000) + else: + patrol_alt = feet(25000) + + builder = WaypointBuilder(flight, self.game, self.is_player) + start = builder.orbit(start, patrol_alt) + + return AwacsFlightPlan( + package=self.package, + flight=flight, + takeoff=builder.takeoff(flight.departure), + nav_to=builder.nav_path(flight.departure.position, start.position, + patrol_alt), + nav_from=builder.nav_path(start.position, flight.arrival.position, + patrol_alt), + land=builder.land(flight.arrival), + divert=builder.divert(flight.divert), + hold=start, + hold_duration=timedelta(hours=4), + ) + def generate_bai(self, flight: Flight) -> StrikeFlightPlan: """Generates a BAI flight plan. @@ -1095,6 +1178,18 @@ class FlightPlanBuilder: start = end.point_from_heading(heading - 180, diameter) return start, end + @staticmethod + def aewc_orbit(location: MissionTarget) -> Point: + closest_airfield = location + # TODO: This is a heading to itself. + # Place this either over the target or as close as possible outside the + # threat zone: https://github.com/Khopa/dcs_liberation/issues/842. + heading = location.position.heading_between_point(closest_airfield.position) + return location.position.point_from_heading( + heading, + 5000 + ) + def racetrack_for_frontline(self, origin: Point, front_line: FrontLine) -> Tuple[Point, Point]: ally_cp, enemy_cp = front_line.control_points diff --git a/gen/flights/waypointbuilder.py b/gen/flights/waypointbuilder.py index ce466b4b..96e17c69 100644 --- a/gen/flights/waypointbuilder.py +++ b/gen/flights/waypointbuilder.py @@ -366,6 +366,26 @@ class WaypointBuilder: return (self.race_track_start(start, altitude), self.race_track_end(end, altitude)) + @staticmethod + def orbit(start: Point, altitude: Distance) -> FlightWaypoint: + """Creates an circular orbit point. + + Args: + start: Position of the waypoint. + altitude: Altitude of the racetrack. + """ + + waypoint = FlightWaypoint( + FlightWaypointType.LOITER, + start.x, + start.y, + altitude + ) + waypoint.name = "ORBIT" + waypoint.description = "Anchor and hold at this point" + waypoint.pretty_name = "Orbit" + return waypoint + @staticmethod def sweep_start(position: Point, altitude: Distance) -> FlightWaypoint: """Creates a sweep start waypoint. diff --git a/qt_ui/widgets/combos/QAircraftTypeSelector.py b/qt_ui/widgets/combos/QAircraftTypeSelector.py index 64e3ab07..dc478c67 100644 --- a/qt_ui/widgets/combos/QAircraftTypeSelector.py +++ b/qt_ui/widgets/combos/QAircraftTypeSelector.py @@ -47,6 +47,9 @@ class QAircraftTypeSelector(QComboBox): elif mission_type in [FlightType.OCA_RUNWAY]: if aircraft in gen.flights.ai_flight_planner_db.RUNWAY_ATTACK_CAPABLE: self.addItem(f"{db.unit_get_expanded_info(self.country, aircraft, 'name')}", userData=aircraft) + elif mission_type in [FlightType.AEWC]: + if aircraft in gen.flights.ai_flight_planner_db.AEWC_CAPABLE: + self.addItem(f"{db.unit_get_expanded_info(self.country, aircraft, 'name')}", 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/basemenu/airfield/QAircraftRecruitmentMenu.py b/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py index c1016b40..b2820a5e 100644 --- a/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py +++ b/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py @@ -12,7 +12,7 @@ from PySide2.QtWidgets import ( QVBoxLayout, QWidget, ) -from dcs.task import CAP, CAS +from dcs.task import CAP, CAS, AWACS from dcs.unittype import FlyingType, UnitType from game import db @@ -45,7 +45,7 @@ class QAircraftRecruitmentMenu(QFrame, QRecruitBehaviour): def init_ui(self): main_layout = QVBoxLayout() - tasks = [CAP, CAS] + tasks = [CAP, CAS, AWACS] scroll_content = QWidget() task_box_layout = QGridLayout()