From 52b63927b42bacbc8991081bbcc1e026d57e9392 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Tue, 22 Dec 2020 12:55:36 -0800 Subject: [PATCH] Prune escorts from packages that don't need them. If the package is not flying into the threat zones of significant air defenses there's no need for SEAD, and packages not near enemy airbases do not need escorts. Prune these flights from the package to save aircraft. --- changelog.md | 1 + gen/flights/ai_flight_planner.py | 146 +++++++++++++++++++++++++------ 2 files changed, 118 insertions(+), 29 deletions(-) diff --git a/changelog.md b/changelog.md index 159a49fd..ef29ddb1 100644 --- a/changelog.md +++ b/changelog.md @@ -4,6 +4,7 @@ Saves from 2.3 are not compatible with 2.4. ## Features/Improvements +* **[Flight Planner]** Air-to-air and SEAD escorts will no longer be automatically planned for packages that are not in range of threats. * **[Plugins]** [The EWRS plugin](https://github.com/Bob7heBuilder/EWRS) is now included. ## Fixes diff --git a/gen/flights/ai_flight_planner.py b/gen/flights/ai_flight_planner.py index c6aad0f1..055be3b9 100644 --- a/gen/flights/ai_flight_planner.py +++ b/gen/flights/ai_flight_planner.py @@ -3,9 +3,12 @@ from __future__ import annotations import logging import operator import random -from dataclasses import dataclass +from collections import defaultdict +from dataclasses import dataclass, field from datetime import timedelta +from enum import Enum, auto from typing import ( + Dict, Iterable, Iterator, List, @@ -57,6 +60,11 @@ if TYPE_CHECKING: from game.inventory import GlobalAircraftInventory +class EscortType(Enum): + AirToAir = auto() + Sead = auto() + + @dataclass(frozen=True) class ProposedFlight: """A flight outline proposed by the mission planner. @@ -75,6 +83,12 @@ class ProposedFlight: #: The maximum distance between the objective and the departure airfield. max_distance: Distance + #: The type of threat this flight defends against if it is an escort. Escort + #: flights will be pruned if the rest of the package is not threatened by + #: the threat they defend against. If this flight is not an escort, this + #: field is None. + escort_type: Optional[EscortType] = field(default=None) + def __str__(self) -> str: return f"{self.task} {self.num_aircraft} ship" @@ -111,7 +125,7 @@ class AircraftAllocator: def find_aircraft_for_flight( self, flight: ProposedFlight - ) -> Optional[Tuple[ControlPoint, FlyingType]]: + ) -> Optional[Tuple[ControlPoint, Type[FlyingType]]]: """Finds aircraft suitable for the given mission. Searches for aircraft capable of performing the given mission within the @@ -198,7 +212,7 @@ class PackageBuilder: self.package.add_flight(flight) return True - def find_divert_field(self, aircraft: FlyingType, + def find_divert_field(self, aircraft: Type[FlyingType], arrival: ControlPoint) -> Optional[ControlPoint]: divert_limit = nautical_miles(150) for airfield in self.closest_airfields.airfields_within(divert_limit): @@ -459,6 +473,7 @@ class CoalitionMissionPlanner: self.is_player = is_player self.objective_finder = ObjectiveFinder(self.game, self.is_player) self.ato = self.game.blue_ato if is_player else self.game.red_ato + self.threat_zones = self.game.threat_zone_for(not self.is_player) self.procurement_requests: List[AircraftProcurementRequest] = [] def propose_missions(self) -> Iterator[ProposedMission]: @@ -472,8 +487,9 @@ class CoalitionMissionPlanner: # Find front lines, plan CAS. for front_line in self.objective_finder.front_lines(): yield ProposedMission(front_line, [ - ProposedFlight(FlightType.TARCAP, 2, self.MAX_CAP_RANGE), ProposedFlight(FlightType.CAS, 2, self.MAX_CAS_RANGE), + ProposedFlight(FlightType.TARCAP, 2, self.MAX_CAP_RANGE, + EscortType.AirToAir), ]) # Find enemy SAM sites with ranges that cover friendly CPs, front lines, @@ -484,21 +500,26 @@ class CoalitionMissionPlanner: yield ProposedMission(sam, [ ProposedFlight(FlightType.DEAD, 2, self.MAX_SEAD_RANGE), # TODO: Max escort range. - ProposedFlight(FlightType.ESCORT, 2, self.MAX_SEAD_RANGE), + ProposedFlight(FlightType.ESCORT, 2, self.MAX_SEAD_RANGE, + EscortType.AirToAir), ]) for group in self.objective_finder.threatening_ships(): yield ProposedMission(group, [ ProposedFlight(FlightType.ANTISHIP, 2, self.MAX_ANTISHIP_RANGE), # TODO: Max escort range. - ProposedFlight(FlightType.ESCORT, 2, self.MAX_ANTISHIP_RANGE), + ProposedFlight(FlightType.ESCORT, 2, self.MAX_ANTISHIP_RANGE, + EscortType.AirToAir), ]) for group in self.objective_finder.threatening_vehicle_groups(): yield ProposedMission(group, [ ProposedFlight(FlightType.BAI, 2, self.MAX_BAI_RANGE), # TODO: Max escort range. - ProposedFlight(FlightType.ESCORT, 2, self.MAX_BAI_RANGE), + ProposedFlight(FlightType.ESCORT, 2, self.MAX_BAI_RANGE, + EscortType.AirToAir), + ProposedFlight(FlightType.SEAD, 2, self.MAX_OCA_RANGE, + EscortType.Sead), ]) for target in self.objective_finder.oca_targets(min_aircraft=20): @@ -506,8 +527,10 @@ class CoalitionMissionPlanner: ProposedFlight(FlightType.OCA_AIRCRAFT, 2, self.MAX_OCA_RANGE), ProposedFlight(FlightType.OCA_RUNWAY, 2, self.MAX_OCA_RANGE), # TODO: Max escort range. - ProposedFlight(FlightType.ESCORT, 2, self.MAX_OCA_RANGE), - ProposedFlight(FlightType.SEAD, 2, self.MAX_OCA_RANGE), + ProposedFlight(FlightType.ESCORT, 2, self.MAX_OCA_RANGE, + EscortType.AirToAir), + ProposedFlight(FlightType.SEAD, 2, self.MAX_OCA_RANGE, + EscortType.Sead), ]) # Plan strike missions. @@ -515,8 +538,10 @@ class CoalitionMissionPlanner: yield ProposedMission(target, [ ProposedFlight(FlightType.STRIKE, 2, self.MAX_STRIKE_RANGE), # TODO: Max escort range. - ProposedFlight(FlightType.SEAD, 2, self.MAX_STRIKE_RANGE), - ProposedFlight(FlightType.ESCORT, 2, self.MAX_STRIKE_RANGE), + ProposedFlight(FlightType.ESCORT, 2, self.MAX_STRIKE_RANGE, + EscortType.AirToAir), + ProposedFlight(FlightType.SEAD, 2, self.MAX_STRIKE_RANGE, + EscortType.Sead), ]) def plan_missions(self) -> None: @@ -532,6 +557,45 @@ class CoalitionMissionPlanner: self.message("Unused aircraft", f"{available} {aircraft.id} from {cp}") + def plan_flight(self, mission: ProposedMission, flight: ProposedFlight, + builder: PackageBuilder, + missing_types: Set[FlightType]) -> None: + if not builder.plan_flight(flight): + missing_types.add(flight.task) + self.procurement_requests.append(AircraftProcurementRequest( + near=mission.location, + range=flight.max_distance, + task_capability=flight.task, + number=flight.num_aircraft + )) + + def scrub_mission_missing_aircraft( + self, mission: ProposedMission, builder: PackageBuilder, + missing_types: Set[FlightType], + not_attempted: Iterable[ProposedFlight]) -> None: + # Try to plan the rest of the mission just so we can count the missing + # types to buy. + for flight in not_attempted: + self.plan_flight(mission, flight, builder, missing_types) + + missing_types_str = ", ".join( + sorted([t.name for t in missing_types])) + builder.release_planned_aircraft() + self.message( + "Insufficient aircraft", + f"Not enough aircraft in range for {mission.location.name} " + f"capable of: {missing_types_str}") + + def check_needed_escorts( + self, builder: PackageBuilder) -> Dict[EscortType, bool]: + threats = defaultdict(bool) + for flight in builder.package.flights: + if self.threat_zones.threatened_by_aircraft(flight): + threats[EscortType.AirToAir] = True + if self.threat_zones.threatened_by_air_defense(flight): + threats[EscortType.Sead] = True + return threats + def plan_mission(self, mission: ProposedMission) -> None: """Allocates aircraft for a proposed mission and adds it to the ATO.""" @@ -548,32 +612,56 @@ class CoalitionMissionPlanner: start_type ) + # Attempt to plan all the main elements of the mission first. Escorts + # will be planned separately so we can prune escorts for packages that + # are not expected to encounter that type of threat. missing_types: Set[FlightType] = set() + escorts = [] for proposed_flight in mission.flights: - if not builder.plan_flight(proposed_flight): - missing_types.add(proposed_flight.task) - self.procurement_requests.append(AircraftProcurementRequest( - near=mission.location, - range=proposed_flight.max_distance, - task_capability=proposed_flight.task, - number=proposed_flight.num_aircraft - )) + if proposed_flight.escort_type is not None: + # Escorts are planned after the primary elements of the package. + # If the package does not need escorts they may be pruned. + escorts.append(proposed_flight) + continue + self.plan_flight(mission, proposed_flight, builder, missing_types) if missing_types: - missing_types_str = ", ".join( - sorted([t.name for t in missing_types])) - builder.release_planned_aircraft() - self.message( - "Insufficient aircraft", - f"Not enough aircraft in range for {mission.location.name} " - f"capable of: {missing_types_str}") + self.scrub_mission_missing_aircraft(mission, builder, missing_types, + escorts) + return + + # Create flight plans for the main flights of the package so we can + # determine threats. This is done *after* creating all of the flights + # rather than as each flight is added because the flight plan for + # flights that will rendezvous with their package will be affected by + # the other flights in the package. Escorts will not be able to + # contribute to this. + flight_plan_builder = FlightPlanBuilder(self.game, builder.package, + self.is_player) + for flight in builder.package.flights: + flight_plan_builder.populate_flight_plan(flight) + + needed_escorts = self.check_needed_escorts(builder) + for escort in escorts: + # This list was generated from the not None set, so this should be + # impossible. + assert escort.escort_type is not None + if needed_escorts[escort.escort_type]: + self.plan_flight(mission, escort, builder, + missing_types) + + # Check again for unavailable aircraft. If the escort was required and + # none were found, scrub the mission. + if missing_types: + self.scrub_mission_missing_aircraft(mission, builder, missing_types, + escorts) return package = builder.build() - flight_plan_builder = FlightPlanBuilder(self.game, package, - self.is_player) + # Add flight plans for escorts. for flight in package.flights: - flight_plan_builder.populate_flight_plan(flight) + if not flight.flight_plan.waypoints: + flight_plan_builder.populate_flight_plan(flight) self.ato.add_package(package) def stagger_missions(self) -> None: