diff --git a/game/ato/flight.py b/game/ato/flight.py index 025a0fd0..db5f5d24 100644 --- a/game/ato/flight.py +++ b/game/ato/flight.py @@ -9,7 +9,7 @@ from dcs.planes import C_101CC, C_101EB, Su_33 from game.savecompat import has_save_compat_for from .flightroster import FlightRoster -from .flightstate import FlightState, InFlight, Navigating, Uninitialized +from .flightstate import FlightState, Navigating, Uninitialized from .flightstate.killed import Killed from .loadouts import Loadout from ..sidc import ( @@ -84,11 +84,10 @@ class Flight(SidcDescribable): # Will be replaced with a more appropriate FlightPlan by # FlightPlanBuilder, but an empty flight plan the flight begins with an # empty flight plan. - from game.ato.flightplan import FlightPlan, CustomFlightPlan + from game.ato.flightplans.flightplan import FlightPlan + from .flightplans.custom import CustomFlightPlan - self.flight_plan: FlightPlan = CustomFlightPlan( - package=package, flight=self, custom_waypoints=[] - ) + self.flight_plan: FlightPlan = CustomFlightPlan(self, []) def __getstate__(self) -> dict[str, Any]: state = self.__dict__.copy() @@ -195,16 +194,11 @@ class Flight(SidcDescribable): return f"[{self.flight_type}] {self.count} x {self.unit_type}" def abort(self) -> None: - from game.ato.flightplan import RtbFlightPlan + from .flightplans.rtb import RtbFlightPlan - if not isinstance(self.state, InFlight): - raise RuntimeError(f"Cannot abort {self} because it is not in flight") - - altitude, altitude_reference = self.state.estimate_altitude() - - self.flight_plan = RtbFlightPlan.create_for_abort( - self, self.state.estimate_position(), altitude, altitude_reference - ) + self.flight_plan = RtbFlightPlan.builder_type()( + self, self.coalition.game.theater + ).build() self.set_state( Navigating( diff --git a/game/ato/flightplan.py b/game/ato/flightplan.py deleted file mode 100644 index d2639616..00000000 --- a/game/ato/flightplan.py +++ /dev/null @@ -1,2201 +0,0 @@ -"""Flight plan generation. - -Flights are first planned generically by either the player or by the -MissionPlanner. Those only plan basic information like the objective, aircraft -type, and the size of the flight. The FlightPlanBuilder is responsible for -generating the waypoints for the mission. -""" -from __future__ import annotations - -import logging -import math -import random -from dataclasses import dataclass, field -from datetime import timedelta -from functools import cached_property -from typing import ( - Iterator, - List, - Optional, - Set, - TYPE_CHECKING, - Tuple, - TypeGuard, -) - -from dcs.mapping import Point -from shapely.geometry import Point as ShapelyPoint - -from game.data.doctrine import Doctrine -from game.dcs.aircrafttype import FuelConsumption -from game.flightplan import HoldZoneGeometry, IpZoneGeometry, JoinZoneGeometry -from game.flightplan.refuelzonegeometry import RefuelZoneGeometry -from game.theater import ( - Airfield, - ConflictTheater, - ControlPoint, - FrontLine, - MissionTarget, - NavalControlPoint, - SamGroundObject, - TheaterGroundObject, - TheaterUnit, -) -from game.theater.theatergroundobject import ( - EwrGroundObject, - NavalGroundObject, -) -from game.typeguard import self_type_guard -from game.utils import Distance, Heading, Speed, feet, knots, meters, nautical_miles -from .closestairfields import ObjectiveDistanceCache -from .flighttype import FlightType -from .flightwaypoint import FlightWaypoint -from .flightwaypointtype import FlightWaypointType -from .starttype import StartType -from .traveltime import GroundSpeed, TravelTime -from .waypointbuilder import StrikeTarget, WaypointBuilder - -if TYPE_CHECKING: - from .flight import Flight - from .package import Package - from game.coalition import Coalition - from game.threatzones import ThreatZones - - -INGRESS_TYPES = { - FlightWaypointType.INGRESS_CAS, - FlightWaypointType.INGRESS_ESCORT, - FlightWaypointType.INGRESS_SEAD, - FlightWaypointType.INGRESS_STRIKE, - FlightWaypointType.INGRESS_DEAD, -} - - -class PlanningError(RuntimeError): - """Raised when the flight planner was unable to create a flight plan.""" - - -class InvalidObjectiveLocation(PlanningError): - """Raised when the objective location is invalid for the mission type.""" - - def __init__(self, task: FlightType, location: MissionTarget) -> None: - super().__init__(f"{location.name} is not valid for {task} missions.") - - -@dataclass(frozen=True) -class FlightPlan: - package: Package - flight: Flight - - @property - def waypoints(self) -> List[FlightWaypoint]: - """A list of all waypoints in the flight plan, in order.""" - return list(self.iter_waypoints()) - - def iter_waypoints(self) -> Iterator[FlightWaypoint]: - """Iterates over all waypoints in the flight plan, in order.""" - raise NotImplementedError - - def edges( - self, until: Optional[FlightWaypoint] = None - ) -> Iterator[Tuple[FlightWaypoint, FlightWaypoint]]: - """A list of all paths between waypoints, in order.""" - waypoints = self.waypoints - if until is None: - last_index = len(waypoints) - else: - last_index = waypoints.index(until) + 1 - - return zip(self.waypoints[:last_index], self.waypoints[1:last_index]) - - def best_speed_between_waypoints( - self, a: FlightWaypoint, b: FlightWaypoint - ) -> Speed: - """Desired ground speed between points a and b.""" - factor = 1.0 - if b.waypoint_type == FlightWaypointType.ASCEND_POINT: - # Flights that start airborne already have some altitude and a good - # amount of speed. - factor = 0.5 - elif b.waypoint_type == FlightWaypointType.LOITER: - # On the way to the hold point the AI won't climb unless they're in - # formation, so slowing down the flight lead gives them more time to - # form up and climb. - # https://forums.eagle.ru/forum/english/digital-combat-simulator/dcs-world-2-5/dcs-wishlist-aa/7121300-ai-flights-will-not-climb-to-hold-point-because-wingman-not-joined - # - # Plus, it's a loiter point so there's no reason to hurry. - factor = 0.75 - # TODO: Adjust if AGL. - # We don't have an exact heightmap, but we should probably be performing - # *some* adjustment for NTTR since the minimum altitude of the map is - # near 2000 ft MSL. - return GroundSpeed.for_flight(self.flight, min(a.alt, b.alt)) * factor - - def speed_between_waypoints(self, a: FlightWaypoint, b: FlightWaypoint) -> Speed: - return self.best_speed_between_waypoints(a, b) - - @property - def combat_speed_waypoints(self) -> set[FlightWaypoint]: - return set() - - def fuel_consumption_between_points( - self, a: FlightWaypoint, b: FlightWaypoint - ) -> Optional[float]: - ppm = self.fuel_rate_to_between_points(a, b) - if ppm is None: - return None - distance = meters(a.position.distance_to_point(b.position)) - return distance.nautical_miles * ppm - - def fuel_rate_to_between_points( - self, a: FlightWaypoint, b: FlightWaypoint - ) -> Optional[float]: - if self.flight.unit_type.fuel_consumption is None: - return None - if a.waypoint_type is FlightWaypointType.TAKEOFF: - return self.flight.unit_type.fuel_consumption.climb - if b in self.combat_speed_waypoints: - return self.flight.unit_type.fuel_consumption.combat - return self.flight.unit_type.fuel_consumption.cruise - - @property - def tot_waypoint(self) -> Optional[FlightWaypoint]: - """The waypoint that is associated with the package TOT, or None. - - Note that the only flight plans that should have no target waypoints are - user-planned missions without any useful waypoints and flight plans that - failed to generate. Nevertheless, we have to defend against it. - """ - raise NotImplementedError - - @property - def tot(self) -> timedelta: - return self.package.time_over_target + self.tot_offset - - @cached_property - def bingo_fuel(self) -> int: - """Bingo fuel value for the FlightPlan""" - if (fuel := self.flight.unit_type.fuel_consumption) is not None: - return self._bingo_estimate(fuel) - return self._legacy_bingo_estimate() - - def _bingo_estimate(self, fuel: FuelConsumption) -> int: - distance_to_arrival = self.max_distance_from(self.flight.arrival) - fuel_consumed = fuel.cruise * distance_to_arrival.nautical_miles - bingo = fuel_consumed + fuel.min_safe - return math.ceil(bingo / 100) * 100 - - def _legacy_bingo_estimate(self) -> int: - distance_to_arrival = self.max_distance_from(self.flight.arrival) - - bingo = 1000.0 # Minimum Emergency Fuel - bingo += 500 # Visual Traffic - bingo += 15 * distance_to_arrival.nautical_miles - - # TODO: Per aircraft tweaks. - - if self.flight.divert is not None: - max_divert_distance = self.max_distance_from(self.flight.divert) - bingo += 10 * max_divert_distance.nautical_miles - - return round(bingo / 100) * 100 - - @cached_property - def joker_fuel(self) -> int: - """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. - """ - if not self.waypoints: - return meters(0) - return max( - [meters(cp.position.distance_to_point(w.position)) for w in self.waypoints] - ) - - @property - def tot_offset(self) -> timedelta: - """This flight's offset from the package's TOT. - - Positive values represent later TOTs. An offset of -2 minutes is used - for a flight that has a TOT 2 minutes before the rest of the package. - """ - return timedelta() - - def _travel_time_to_waypoint(self, destination: FlightWaypoint) -> timedelta: - total = timedelta() - - if destination not in self.waypoints: - raise PlanningError( - f"Did not find destination waypoint {destination} in " - f"waypoints for {self.flight}" - ) - - for previous_waypoint, waypoint in self.edges(until=destination): - total += self.travel_time_between_waypoints(previous_waypoint, waypoint) - return total - - def travel_time_between_waypoints( - self, a: FlightWaypoint, b: FlightWaypoint - ) -> timedelta: - return TravelTime.between_points( - a.position, b.position, self.speed_between_waypoints(a, b) - ) - - def tot_for_waypoint(self, waypoint: FlightWaypoint) -> Optional[timedelta]: - raise NotImplementedError - - def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> Optional[timedelta]: - raise NotImplementedError - - def request_escort_at(self) -> Optional[FlightWaypoint]: - return None - - def dismiss_escort_at(self) -> Optional[FlightWaypoint]: - return None - - def escorted_waypoints(self) -> Iterator[FlightWaypoint]: - begin = self.request_escort_at() - end = self.dismiss_escort_at() - if begin is None or end is None: - return - escorting = False - for waypoint in self.waypoints: - if waypoint == begin: - escorting = True - if escorting: - yield waypoint - if waypoint == end: - return - - def takeoff_time(self) -> Optional[timedelta]: - tot_waypoint = self.tot_waypoint - if tot_waypoint is None: - return None - return self.tot - self._travel_time_to_waypoint(tot_waypoint) - - def startup_time(self) -> Optional[timedelta]: - takeoff_time = self.takeoff_time() - if takeoff_time is None: - return None - - start_time: timedelta = ( - takeoff_time - self.estimate_startup() - self.estimate_ground_ops() - ) - - # In case FP math has given us some barely below zero time, round to - # zero. - if math.isclose(start_time.total_seconds(), 0): - start_time = timedelta() - - # Trim microseconds. DCS doesn't handle sub-second resolution for tasks, - # and they're not interesting from a mission planning perspective so we - # don't want them in the UI. - # - # Round down so *barely* above zero start times are just zero. - start_time = timedelta(seconds=math.floor(start_time.total_seconds())) - - # Feature request #1309: Carrier planes should start at +1s - # This is a workaround to a DCS problem: some AI planes spawn on - # the 'sixpack' when start_time is zero and cause a deadlock. - # Workaround: force the start_time to 1 second for these planes. - if self.flight.from_cp.is_fleet and start_time.total_seconds() == 0: - start_time = timedelta(seconds=1) - - return start_time - - def estimate_startup(self) -> timedelta: - if self.flight.start_type is StartType.COLD: - if self.flight.client_count: - return timedelta(minutes=10) - else: - # The AI doesn't seem to have a real startup procedure. - return timedelta(minutes=2) - return timedelta() - - def estimate_ground_ops(self) -> timedelta: - if self.flight.start_type in {StartType.RUNWAY, StartType.IN_FLIGHT}: - return timedelta() - if self.flight.from_cp.is_fleet: - return timedelta(minutes=2) - else: - return timedelta(minutes=8) - - @property - def mission_departure_time(self) -> timedelta: - """The time that the mission is complete and the flight RTBs.""" - raise NotImplementedError - - @self_type_guard - def is_loiter(self, flight_plan: FlightPlan) -> TypeGuard[LoiterFlightPlan]: - return False - - @self_type_guard - def is_patrol(self, flight_plan: FlightPlan) -> TypeGuard[PatrollingFlightPlan]: - return False - - @self_type_guard - def is_formation(self, flight_plan: FlightPlan) -> TypeGuard[FormationFlightPlan]: - return False - - -@dataclass(frozen=True) -class LoiterFlightPlan(FlightPlan): - hold: FlightWaypoint - hold_duration: timedelta - - def iter_waypoints(self) -> Iterator[FlightWaypoint]: - raise NotImplementedError - - @property - def tot_waypoint(self) -> Optional[FlightWaypoint]: - raise NotImplementedError - - def tot_for_waypoint(self, waypoint: FlightWaypoint) -> Optional[timedelta]: - raise NotImplementedError - - @property - def push_time(self) -> timedelta: - raise NotImplementedError - - def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> Optional[timedelta]: - if waypoint == self.hold: - return self.push_time - return None - - def travel_time_between_waypoints( - self, a: FlightWaypoint, b: FlightWaypoint - ) -> timedelta: - travel_time = super().travel_time_between_waypoints(a, b) - if a != self.hold: - return travel_time - return travel_time + self.hold_duration - - @property - def mission_departure_time(self) -> timedelta: - raise NotImplementedError - - @self_type_guard - def is_loiter(self, flight_plan: FlightPlan) -> TypeGuard[LoiterFlightPlan]: - return True - - -@dataclass(frozen=True) -class FormationFlightPlan(LoiterFlightPlan): - join: FlightWaypoint - split: FlightWaypoint - refuel: FlightWaypoint - - def iter_waypoints(self) -> Iterator[FlightWaypoint]: - raise NotImplementedError - - @property - def package_speed_waypoints(self) -> set[FlightWaypoint]: - raise NotImplementedError - - @property - def combat_speed_waypoints(self) -> set[FlightWaypoint]: - return self.package_speed_waypoints - - @property - def tot_waypoint(self) -> Optional[FlightWaypoint]: - raise NotImplementedError - - def request_escort_at(self) -> Optional[FlightWaypoint]: - return self.join - - def dismiss_escort_at(self) -> Optional[FlightWaypoint]: - return self.split - - @cached_property - def best_flight_formation_speed(self) -> Speed: - """The best speed this flight is capable at all formation waypoints. - - To ease coordination with other flights, we aim to have a single mission - speed used by the formation for all waypoints. As such, this function - returns the highest ground speed that the flight is capable of flying at - all of its formation waypoints. - """ - speeds = [] - for previous_waypoint, waypoint in self.edges(): - if waypoint in self.package_speed_waypoints: - speeds.append( - self.best_speed_between_waypoints(previous_waypoint, waypoint) - ) - return min(speeds) - - def speed_between_waypoints(self, a: FlightWaypoint, b: FlightWaypoint) -> Speed: - if b in self.package_speed_waypoints: - # Should be impossible, as any package with at least one - # FormationFlightPlan flight needs a formation speed. - assert self.package.formation_speed is not None - return self.package.formation_speed - return super().speed_between_waypoints(a, b) - - @property - def travel_time_to_rendezvous(self) -> timedelta: - """The estimated time between the first waypoint and the join point.""" - return self._travel_time_to_waypoint(self.join) - - @property - def join_time(self) -> timedelta: - raise NotImplementedError - - @property - def split_time(self) -> timedelta: - raise NotImplementedError - - def tot_for_waypoint(self, waypoint: FlightWaypoint) -> Optional[timedelta]: - if waypoint == self.join: - return self.join_time - elif waypoint == self.split: - return self.split_time - return None - - @property - def push_time(self) -> timedelta: - return self.join_time - TravelTime.between_points( - self.hold.position, - self.join.position, - GroundSpeed.for_flight(self.flight, self.hold.alt), - ) - - @property - def mission_departure_time(self) -> timedelta: - return self.split_time - - @self_type_guard - def is_formation(self, flight_plan: FlightPlan) -> TypeGuard[FormationFlightPlan]: - return True - - -@dataclass(frozen=True) -class PatrollingFlightPlan(FlightPlan): - nav_to: List[FlightWaypoint] - nav_from: List[FlightWaypoint] - patrol_start: FlightWaypoint - patrol_end: FlightWaypoint - - #: Maximum time to remain on station. - patrol_duration: timedelta - - #: Racetrack speed TAS. - patrol_speed: Speed - - #: The engagement range of any Search Then Engage task, or the radius of a - #: Search Then Engage in Zone task. Any enemies of the appropriate type for - #: this mission within this range of the flight's current position (or the - #: center of the zone) will be engaged by the flight. - engagement_distance: Distance - - @property - def patrol_start_time(self) -> timedelta: - return self.package.time_over_target - - @property - def patrol_end_time(self) -> timedelta: - # TODO: This is currently wrong for CAS. - # CAS missions end when they're winchester or bingo. We need to - # configure push tasks for the escorts rather than relying on timing. - return self.patrol_start_time + self.patrol_duration - - def tot_for_waypoint(self, waypoint: FlightWaypoint) -> Optional[timedelta]: - if waypoint == self.patrol_start: - return self.patrol_start_time - return None - - def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> Optional[timedelta]: - if waypoint == self.patrol_end: - return self.patrol_end_time - return None - - def iter_waypoints(self) -> Iterator[FlightWaypoint]: - raise NotImplementedError - - @property - def package_speed_waypoints(self) -> Set[FlightWaypoint]: - return {self.patrol_start, self.patrol_end} - - @property - def tot_waypoint(self) -> Optional[FlightWaypoint]: - return self.patrol_start - - @property - def mission_departure_time(self) -> timedelta: - return self.patrol_end_time - - @self_type_guard - def is_patrol(self, flight_plan: FlightPlan) -> TypeGuard[PatrollingFlightPlan]: - return True - - -@dataclass(frozen=True) -class BarCapFlightPlan(PatrollingFlightPlan): - takeoff: FlightWaypoint - land: FlightWaypoint - divert: Optional[FlightWaypoint] - bullseye: FlightWaypoint - - def iter_waypoints(self) -> Iterator[FlightWaypoint]: - yield self.takeoff - yield from self.nav_to - yield from [ - self.patrol_start, - self.patrol_end, - ] - yield from self.nav_from - yield self.land - if self.divert is not None: - yield self.divert - yield self.bullseye - - -@dataclass(frozen=True) -class CasFlightPlan(PatrollingFlightPlan): - takeoff: FlightWaypoint - target: FlightWaypoint - land: FlightWaypoint - divert: Optional[FlightWaypoint] - bullseye: FlightWaypoint - - def iter_waypoints(self) -> Iterator[FlightWaypoint]: - yield self.takeoff - yield from self.nav_to - yield from [ - self.patrol_start, - self.target, - self.patrol_end, - ] - yield from self.nav_from - yield self.land - if self.divert is not None: - yield self.divert - yield self.bullseye - - @property - def combat_speed_waypoints(self) -> set[FlightWaypoint]: - return {self.patrol_start, self.target, self.patrol_end} - - def request_escort_at(self) -> Optional[FlightWaypoint]: - return self.patrol_start - - def dismiss_escort_at(self) -> Optional[FlightWaypoint]: - return self.patrol_end - - -@dataclass(frozen=True) -class TarCapFlightPlan(PatrollingFlightPlan): - takeoff: FlightWaypoint - refuel: Optional[FlightWaypoint] - land: FlightWaypoint - divert: Optional[FlightWaypoint] - bullseye: FlightWaypoint - lead_time: timedelta - - def iter_waypoints(self) -> Iterator[FlightWaypoint]: - yield self.takeoff - yield from self.nav_to - yield from [ - self.patrol_start, - self.patrol_end, - ] - if self.refuel is not None: - yield self.refuel - yield from self.nav_from - yield self.land - if self.divert is not None: - yield self.divert - yield self.bullseye - - @property - def combat_speed_waypoints(self) -> set[FlightWaypoint]: - return {self.patrol_start, self.patrol_end} - - @property - def tot_offset(self) -> timedelta: - return -self.lead_time - - def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> Optional[timedelta]: - if waypoint == self.patrol_end: - return self.patrol_end_time - return super().depart_time_for_waypoint(waypoint) - - @property - def patrol_start_time(self) -> timedelta: - start = self.package.escort_start_time - if start is not None: - return start + self.tot_offset - return self.tot - - @property - def patrol_end_time(self) -> timedelta: - end = self.package.escort_end_time - if end is not None: - return end - return super().patrol_end_time - - -@dataclass(frozen=True) -class StrikeFlightPlan(FormationFlightPlan): - takeoff: FlightWaypoint - hold: FlightWaypoint - nav_to: List[FlightWaypoint] - join: FlightWaypoint - ingress: FlightWaypoint - targets: List[FlightWaypoint] - split: FlightWaypoint - nav_from: List[FlightWaypoint] - land: FlightWaypoint - divert: Optional[FlightWaypoint] - bullseye: FlightWaypoint - lead_time: timedelta = field(default_factory=timedelta) - - def iter_waypoints(self) -> Iterator[FlightWaypoint]: - yield self.takeoff - yield self.hold - yield from self.nav_to - yield self.join - yield self.ingress - yield from self.targets - yield self.split - if self.refuel is not None: - yield self.refuel - yield from self.nav_from - yield self.land - if self.divert is not None: - yield self.divert - yield self.bullseye - - @property - def package_speed_waypoints(self) -> set[FlightWaypoint]: - return { - self.ingress, - self.split, - } | set(self.targets) - - def speed_between_waypoints(self, a: FlightWaypoint, b: FlightWaypoint) -> Speed: - # FlightWaypoint is only comparable by identity, so adding - # target_area_waypoint to package_speed_waypoints is useless. - if b.waypoint_type == FlightWaypointType.TARGET_GROUP_LOC: - # Should be impossible, as any package with at least one - # FormationFlightPlan flight needs a formation speed. - assert self.package.formation_speed is not None - return self.package.formation_speed - return super().speed_between_waypoints(a, b) - - @property - def tot_waypoint(self) -> FlightWaypoint: - return self.targets[0] - - @property - def tot_offset(self) -> timedelta: - try: - return -self.lead_time - except AttributeError: - return timedelta() - - @property - def target_area_waypoint(self) -> FlightWaypoint: - return FlightWaypoint( - "TARGET AREA", - FlightWaypointType.TARGET_GROUP_LOC, - self.package.target.position, - meters(0), - "RADIO", - ) - - @property - def travel_time_to_target(self) -> timedelta: - """The estimated time between the first waypoint and the target.""" - destination = self.tot_waypoint - total = timedelta() - for previous_waypoint, waypoint in self.edges(): - if waypoint == self.tot_waypoint: - # For anything strike-like the TOT waypoint is the *flight's* - # mission target, but to synchronize with the rest of the - # package we need to use the travel time to the same position as - # the others. - total += self.travel_time_between_waypoints( - previous_waypoint, self.target_area_waypoint - ) - break - total += self.travel_time_between_waypoints(previous_waypoint, waypoint) - else: - raise PlanningError( - f"Did not find destination waypoint {destination} in " - f"waypoints for {self.flight}" - ) - return total - - @property - def join_time(self) -> timedelta: - travel_time = self.travel_time_between_waypoints(self.join, self.ingress) - return self.ingress_time - travel_time - - @property - def split_time(self) -> timedelta: - travel_time_ingress = self.travel_time_between_waypoints( - self.ingress, self.target_area_waypoint - ) - travel_time_egress = self.travel_time_between_waypoints( - self.target_area_waypoint, self.split - ) - minutes_at_target = 0.75 * len(self.targets) - timedelta_at_target = timedelta(minutes=minutes_at_target) - return ( - self.ingress_time - + travel_time_ingress - + timedelta_at_target - + travel_time_egress - ) - - @property - def ingress_time(self) -> timedelta: - tot = self.tot - travel_time = self.travel_time_between_waypoints( - self.ingress, self.target_area_waypoint - ) - return tot - travel_time - - def tot_for_waypoint(self, waypoint: FlightWaypoint) -> Optional[timedelta]: - if waypoint == self.ingress: - return self.ingress_time - elif waypoint in self.targets: - return self.tot - return super().tot_for_waypoint(waypoint) - - -@dataclass(frozen=True) -class SweepFlightPlan(LoiterFlightPlan): - takeoff: FlightWaypoint - nav_to: List[FlightWaypoint] - sweep_start: FlightWaypoint - sweep_end: FlightWaypoint - refuel: FlightWaypoint - nav_from: List[FlightWaypoint] - land: FlightWaypoint - divert: Optional[FlightWaypoint] - bullseye: FlightWaypoint - lead_time: timedelta - - def iter_waypoints(self) -> Iterator[FlightWaypoint]: - yield self.takeoff - yield self.hold - yield from self.nav_to - yield self.sweep_start - yield self.sweep_end - if self.refuel is not None: - yield self.refuel - yield from self.nav_from - yield self.land - if self.divert is not None: - yield self.divert - yield self.bullseye - - @property - def combat_speed_waypoints(self) -> set[FlightWaypoint]: - return {self.sweep_end} - - @property - def tot_waypoint(self) -> Optional[FlightWaypoint]: - return self.sweep_end - - @property - def tot_offset(self) -> timedelta: - return -self.lead_time - - @property - def sweep_start_time(self) -> timedelta: - travel_time = self.travel_time_between_waypoints( - self.sweep_start, self.sweep_end - ) - return self.sweep_end_time - travel_time - - @property - def sweep_end_time(self) -> timedelta: - return self.tot - - def tot_for_waypoint(self, waypoint: FlightWaypoint) -> Optional[timedelta]: - if waypoint == self.sweep_start: - return self.sweep_start_time - if waypoint == self.sweep_end: - return self.sweep_end_time - return None - - def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> Optional[timedelta]: - if waypoint == self.hold: - return self.push_time - return None - - @property - def push_time(self) -> timedelta: - return self.sweep_end_time - TravelTime.between_points( - self.hold.position, - self.sweep_end.position, - GroundSpeed.for_flight(self.flight, self.hold.alt), - ) - - def mission_departure_time(self) -> timedelta: - return self.sweep_end_time - - -@dataclass(frozen=True) -class AwacsFlightPlan(PatrollingFlightPlan): - takeoff: FlightWaypoint - land: FlightWaypoint - divert: Optional[FlightWaypoint] - bullseye: FlightWaypoint - - @property - def patrol_start_time(self) -> timedelta: - return self.package.time_over_target - - @property - def mission_start_time(self) -> Optional[timedelta]: - return self.takeoff_time() - - @property - def mission_departure_time(self) -> timedelta: - return self.patrol_start_time + self.patrol_duration - - @property - def tot_waypoint(self) -> Optional[FlightWaypoint]: - return self.patrol_start - - def iter_waypoints(self) -> Iterator[FlightWaypoint]: - yield self.takeoff - yield from self.nav_to - yield self.patrol_start - yield self.patrol_end - yield from self.nav_from - yield self.land - if self.divert is not None: - yield self.divert - yield self.bullseye - - -@dataclass(frozen=True) -class RefuelingFlightPlan(PatrollingFlightPlan): - takeoff: FlightWaypoint - land: FlightWaypoint - divert: Optional[FlightWaypoint] - bullseye: FlightWaypoint - - @property - def patrol_start_time(self) -> timedelta: - return self.package.time_over_target - - def iter_waypoints(self) -> Iterator[FlightWaypoint]: - yield self.takeoff - yield from self.nav_to - yield self.patrol_start - yield self.patrol_end - yield from self.nav_from - yield self.land - if self.divert is not None: - yield self.divert - yield self.bullseye - - -@dataclass(frozen=True) -class PackageRefuelingFlightPlan(RefuelingFlightPlan): - def target_area_waypoint(self) -> FlightWaypoint: - return FlightWaypoint( - "TARGET AREA", - FlightWaypointType.TARGET_GROUP_LOC, - self.package.target.position, - meters(0), - "RADIO", - ) - - @property - def patrol_start_time(self) -> timedelta: - altitude: Optional[Distance] = self.flight.unit_type.patrol_altitude - - if altitude is None: - altitude = Distance.from_feet(20000) - - assert self.package.waypoints is not None - - # Cheat in a FlightWaypoint for the split point. - split: Point = self.package.waypoints.split - split_waypoint: FlightWaypoint = FlightWaypoint( - "SPLIT", FlightWaypointType.SPLIT, split, altitude - ) - - # Cheat in a FlightWaypoint for the refuel point. - refuel: Point = self.package.waypoints.refuel - refuel_waypoint: FlightWaypoint = FlightWaypoint( - "REFUEL", FlightWaypointType.REFUEL, refuel, altitude - ) - - delay_target_to_split: timedelta = self.travel_time_between_waypoints( - self.target_area_waypoint(), split_waypoint - ) - delay_split_to_refuel: timedelta = self.travel_time_between_waypoints( - split_waypoint, refuel_waypoint - ) - - return ( - self.package.time_over_target - + delay_target_to_split - + delay_split_to_refuel - - timedelta(minutes=1.5) - ) - - -@dataclass(frozen=True) -class AirliftFlightPlan(FlightPlan): - takeoff: FlightWaypoint - nav_to_pickup: List[FlightWaypoint] - pickup: Optional[FlightWaypoint] - nav_to_drop_off: List[FlightWaypoint] - drop_off: FlightWaypoint - nav_to_home: List[FlightWaypoint] - land: FlightWaypoint - divert: Optional[FlightWaypoint] - bullseye: FlightWaypoint - - def iter_waypoints(self) -> Iterator[FlightWaypoint]: - yield self.takeoff - yield from self.nav_to_pickup - if self.pickup: - yield self.pickup - yield from self.nav_to_drop_off - yield self.drop_off - yield from self.nav_to_home - yield self.land - if self.divert is not None: - yield self.divert - yield self.bullseye - - @property - def tot_waypoint(self) -> Optional[FlightWaypoint]: - return self.drop_off - - def tot_for_waypoint(self, waypoint: FlightWaypoint) -> Optional[timedelta]: - # TOT planning isn't really useful for transports. They're behind the front - # lines so no need to wait for escorts or for other missions to complete. - return None - - def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> Optional[timedelta]: - return None - - @property - def mission_departure_time(self) -> timedelta: - return self.package.time_over_target - - -@dataclass(frozen=True) -class FerryFlightPlan(FlightPlan): - takeoff: FlightWaypoint - nav_to_destination: list[FlightWaypoint] - land: FlightWaypoint - divert: Optional[FlightWaypoint] - bullseye: FlightWaypoint - - def iter_waypoints(self) -> Iterator[FlightWaypoint]: - yield self.takeoff - yield from self.nav_to_destination - yield self.land - if self.divert is not None: - yield self.divert - yield self.bullseye - - @property - def tot_waypoint(self) -> Optional[FlightWaypoint]: - return self.land - - def tot_for_waypoint(self, waypoint: FlightWaypoint) -> Optional[timedelta]: - # TOT planning isn't really useful for ferries. They're behind the front - # lines so no need to wait for escorts or for other missions to complete. - return None - - def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> Optional[timedelta]: - return None - - @property - def mission_departure_time(self) -> timedelta: - return self.package.time_over_target - - -@dataclass(frozen=True) -class RtbFlightPlan(FlightPlan): - takeoff: FlightWaypoint - abort_location: FlightWaypoint - nav_to_destination: list[FlightWaypoint] - land: FlightWaypoint - divert: Optional[FlightWaypoint] - bullseye: FlightWaypoint - - def iter_waypoints(self) -> Iterator[FlightWaypoint]: - yield self.takeoff - yield self.abort_location - yield from self.nav_to_destination - yield self.land - if self.divert is not None: - yield self.divert - yield self.bullseye - - @property - def abort_index(self) -> int: - return 1 - - @property - def tot_waypoint(self) -> Optional[FlightWaypoint]: - return None - - def tot_for_waypoint(self, waypoint: FlightWaypoint) -> Optional[timedelta]: - return None - - def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> Optional[timedelta]: - return None - - @property - def mission_departure_time(self) -> timedelta: - return timedelta() - - @staticmethod - def create_for_abort( - flight: Flight, - current_position: Point, - current_altitude: Distance, - altitude_reference: str, - ) -> RtbFlightPlan: - altitude_is_agl = flight.unit_type.dcs_unit_type.helicopter - altitude = ( - feet(1500) - if altitude_is_agl - else flight.unit_type.preferred_patrol_altitude - ) - builder = WaypointBuilder(flight, flight.coalition) - abort_point = builder.nav( - current_position, current_altitude, altitude_reference == "RADIO" - ) - abort_point.name = "ABORT AND RTB" - abort_point.pretty_name = "Abort and RTB" - abort_point.description = "Abort mission and return to base" - return RtbFlightPlan( - package=flight.package, - flight=flight, - takeoff=builder.takeoff(flight.departure), - abort_location=abort_point, - nav_to_destination=builder.nav_path( - current_position, flight.arrival.position, altitude, altitude_is_agl - ), - land=builder.land(flight.arrival), - divert=builder.divert(flight.divert), - bullseye=builder.bullseye(), - ) - - -@dataclass(frozen=True) -class CustomFlightPlan(FlightPlan): - custom_waypoints: List[FlightWaypoint] - - def iter_waypoints(self) -> Iterator[FlightWaypoint]: - yield from self.custom_waypoints - - @property - def tot_waypoint(self) -> Optional[FlightWaypoint]: - target_types = ( - FlightWaypointType.PATROL_TRACK, - FlightWaypointType.TARGET_GROUP_LOC, - FlightWaypointType.TARGET_POINT, - FlightWaypointType.TARGET_SHIP, - ) - for waypoint in self.waypoints: - if waypoint in target_types: - return waypoint - return None - - def tot_for_waypoint(self, waypoint: FlightWaypoint) -> Optional[timedelta]: - if waypoint == self.tot_waypoint: - return self.package.time_over_target - return None - - def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> Optional[timedelta]: - return None - - @property - def mission_departure_time(self) -> timedelta: - return self.package.time_over_target - - -class FlightPlanBuilder: - """Generates flight plans for flights.""" - - def __init__( - self, package: Package, coalition: Coalition, theater: ConflictTheater - ) -> None: - # TODO: Plan similar altitudes for the in-country leg of the mission. - # Waypoint altitudes for a given flight *shouldn't* differ too much - # between the join and split points, so we don't need speeds for each - # leg individually since they should all be fairly similar. This doesn't - # hold too well right now since nothing is stopping each waypoint from - # jumping 20k feet each time, but that's a huge waste of energy we - # should be avoiding anyway. - self.package = package - self.coalition = coalition - self.theater = theater - - @property - def is_player(self) -> bool: - return self.coalition.player - - @property - def doctrine(self) -> Doctrine: - return self.coalition.doctrine - - @property - def threat_zones(self) -> ThreatZones: - return self.coalition.opponent.threat_zone - - def populate_flight_plan( - self, - flight: Flight, - # TODO: Custom targets should be an attribute of the flight. - custom_targets: Optional[List[TheaterUnit]] = None, - ) -> None: - """Creates a default flight plan for the given mission.""" - if flight not in self.package.flights: - raise RuntimeError("Flight must be a part of the package") - - from game.navmesh import NavMeshError - - try: - if self.package.waypoints is None: - self.regenerate_package_waypoints() - flight.flight_plan = self.generate_flight_plan(flight, custom_targets) - except NavMeshError as ex: - color = "blue" if self.is_player else "red" - raise PlanningError( - f"Could not plan {color} {flight.flight_type.value} from " - f"{flight.departure} to {flight.package.target}" - ) from ex - - def generate_flight_plan( - self, flight: Flight, custom_targets: Optional[List[TheaterUnit]] - ) -> FlightPlan: - # TODO: Flesh out mission types. - task = flight.flight_type - if task == FlightType.ANTISHIP: - return self.generate_anti_ship(flight) - elif task == FlightType.BAI: - return self.generate_bai(flight) - elif task == FlightType.BARCAP: - return self.generate_barcap(flight) - elif task == FlightType.CAS: - return self.generate_cas(flight) - elif task == FlightType.DEAD: - return self.generate_dead(flight, custom_targets) - elif task == FlightType.ESCORT: - return self.generate_escort(flight) - elif task == FlightType.OCA_AIRCRAFT: - return self.generate_oca_strike(flight) - elif task == FlightType.OCA_RUNWAY: - return self.generate_runway_attack(flight) - elif task == FlightType.SEAD: - return self.generate_sead(flight, custom_targets) - elif task == FlightType.SEAD_ESCORT: - return self.generate_escort(flight) - elif task == FlightType.STRIKE: - return self.generate_strike(flight) - elif task == FlightType.SWEEP: - return self.generate_sweep(flight) - elif task == FlightType.TARCAP: - return self.generate_tarcap(flight) - elif task == FlightType.AEWC: - return self.generate_aewc_racetrack(flight) - elif task == FlightType.TRANSPORT: - return self.generate_transport(flight) - elif task == FlightType.REFUELING: - if self.package.target.is_friendly(self.is_player) or isinstance( - self.package.target, FrontLine - ): - return self.generate_refueling_racetrack(flight) - else: - return self.generate_refueling_package_support(flight) - elif task == FlightType.FERRY: - return self.generate_ferry(flight) - raise PlanningError(f"{task} flight plan generation not implemented") - - def regenerate_flight_plans(self) -> None: - new_flights: list[Flight] = [] - for old_flight in self.package.flights: - # TODO: Don't lose custom targets here. - old_flight.flight_plan = self.generate_flight_plan(old_flight, None) - new_flights.append(old_flight) - self.package.flights = new_flights - - def regenerate_package_waypoints(self) -> None: - from game.ato.packagewaypoints import PackageWaypoints - - package_airfield = self.package_airfield() - - # Start by picking the best IP for the attack. - ingress_point = IpZoneGeometry( - self.package.target.position, - package_airfield.position, - self.coalition, - ).find_best_ip() - - join_point = JoinZoneGeometry( - self.package.target.position, - package_airfield.position, - ingress_point, - self.coalition, - ).find_best_join_point() - - refuel_point = RefuelZoneGeometry( - package_airfield.position, - join_point, - self.coalition, - ).find_best_refuel_point() - - # And the split point based on the best route from the IP. Since that's no - # different than the best route *to* the IP, this is the same as the join point. - # TODO: Estimate attack completion point based on the IP and split from there? - self.package.waypoints = PackageWaypoints( - WaypointBuilder.perturb(join_point), - ingress_point, - WaypointBuilder.perturb(join_point), - refuel_point, - ) - - def generate_strike(self, flight: Flight) -> StrikeFlightPlan: - """Generates a strike flight plan. - - Args: - flight: The flight to generate the flight plan for. - """ - location = self.package.target - - # TODO: Support airfield strikes. - if not isinstance(location, TheaterGroundObject): - raise InvalidObjectiveLocation(flight.flight_type, location) - - targets: List[StrikeTarget] = [] - - for j, u in enumerate(location.strike_targets): - targets.append(StrikeTarget(f"{u.type.id} #{j}", u)) - - return self.strike_flightplan( - flight, location, FlightWaypointType.INGRESS_STRIKE, targets - ) - - def generate_aewc_racetrack(self, flight: Flight) -> AwacsFlightPlan: - """Generate a AWACS flight in racetrack orbit. - - Args: - flight: The flight to generate the flight plan for. - """ - - racetrack_half_distance = Distance.from_nautical_miles(30).meters - - patrol_duration = timedelta(hours=4) - - location = self.package.target - - closest_boundary = self.threat_zones.closest_boundary(location.position) - heading_to_threat_boundary = Heading.from_degrees( - location.position.heading_between_point(closest_boundary) - ) - distance_to_threat = meters( - location.position.distance_to_point(closest_boundary) - ) - orbit_heading = heading_to_threat_boundary - - # Station 80nm outside the threat zone. - threat_buffer = nautical_miles(80) - if self.threat_zones.threatened(location.position): - orbit_distance = distance_to_threat + threat_buffer - else: - orbit_distance = distance_to_threat - threat_buffer - - racetrack_center = location.position.point_from_heading( - orbit_heading.degrees, orbit_distance.meters - ) - - racetrack_start = racetrack_center.point_from_heading( - orbit_heading.right.degrees, racetrack_half_distance - ) - - racetrack_end = racetrack_center.point_from_heading( - orbit_heading.left.degrees, racetrack_half_distance - ) - - builder = WaypointBuilder(flight, self.coalition) - - if flight.unit_type.patrol_altitude is not None: - altitude = flight.unit_type.patrol_altitude - else: - altitude = feet(25000) - - if flight.unit_type.preferred_patrol_speed(altitude) is not None: - speed = flight.unit_type.preferred_patrol_speed(altitude) - else: - speed = knots(390) - - racetrack = builder.race_track(racetrack_start, racetrack_end, altitude) - - return AwacsFlightPlan( - package=self.package, - flight=flight, - takeoff=builder.takeoff(flight.departure), - nav_to=builder.nav_path( - flight.departure.position, racetrack_start, altitude - ), - nav_from=builder.nav_path(racetrack_end, flight.arrival.position, altitude), - patrol_start=racetrack[0], - patrol_end=racetrack[1], - land=builder.land(flight.arrival), - divert=builder.divert(flight.divert), - bullseye=builder.bullseye(), - patrol_duration=patrol_duration, - patrol_speed=speed, - # TODO: Factor out a common base of the combat and non-combat race-tracks. - # No harm in setting this, but we ought to clean up a bit. - engagement_distance=meters(0), - ) - - def generate_bai(self, flight: Flight) -> FlightPlan: - """Generates a BAI flight plan. - - Args: - flight: The flight to generate the flight plan for. - """ - location = self.package.target - - from game.transfers import Convoy - - targets: List[StrikeTarget] = [] - if isinstance(location, TheaterGroundObject): - for group in location.groups: - if group.units: - targets.append( - StrikeTarget(f"{group.group_name} at {location.name}", group) - ) - elif isinstance(location, Convoy): - targets.append(StrikeTarget(location.name, location)) - else: - raise InvalidObjectiveLocation(flight.flight_type, location) - - return self.strike_flightplan( - flight, location, FlightWaypointType.INGRESS_BAI, targets - ) - - @staticmethod - def anti_ship_targets_for_tgo(tgo: NavalGroundObject) -> List[StrikeTarget]: - return [StrikeTarget(f"{g.group_name} at {tgo.name}", g) for g in tgo.groups] - - def generate_anti_ship(self, flight: Flight) -> StrikeFlightPlan: - """Generates an anti-ship flight plan. - - Args: - flight: The flight to generate the flight plan for. - """ - location = self.package.target - - from game.transfers import CargoShip - - if isinstance(location, NavalControlPoint): - targets = self.anti_ship_targets_for_tgo(location.find_main_tgo()) - elif isinstance(location, NavalGroundObject): - targets = self.anti_ship_targets_for_tgo(location) - elif isinstance(location, CargoShip): - targets = [StrikeTarget(location.name, location)] - else: - raise InvalidObjectiveLocation(flight.flight_type, location) - - return self.strike_flightplan( - flight, location, FlightWaypointType.INGRESS_BAI, targets - ) - - def generate_barcap(self, flight: Flight) -> BarCapFlightPlan: - """Generate a BARCAP flight at a given location. - - Args: - flight: The flight to generate the flight plan for. - """ - location = self.package.target - - if isinstance(location, FrontLine): - raise InvalidObjectiveLocation(flight.flight_type, location) - - start_pos, end_pos = self.cap_racetrack_for_objective(location, barcap=True) - - preferred_alt = flight.unit_type.preferred_patrol_altitude - randomized_alt = preferred_alt + feet(random.randint(-2, 1) * 1000) - patrol_alt = max( - self.doctrine.min_patrol_altitude, - min(self.doctrine.max_patrol_altitude, randomized_alt), - ) - - patrol_speed = flight.unit_type.preferred_patrol_speed(patrol_alt) - logging.debug( - f"BARCAP patrol speed for {flight.unit_type.name} at {patrol_alt.feet}ft: {patrol_speed.knots} KTAS" - ) - - builder = WaypointBuilder(flight, self.coalition) - start, end = builder.race_track(start_pos, end_pos, patrol_alt) - - return BarCapFlightPlan( - package=self.package, - flight=flight, - patrol_duration=self.doctrine.cap_duration, - patrol_speed=patrol_speed, - engagement_distance=self.doctrine.cap_engagement_range, - takeoff=builder.takeoff(flight.departure), - nav_to=builder.nav_path( - flight.departure.position, start.position, patrol_alt - ), - nav_from=builder.nav_path( - end.position, flight.arrival.position, patrol_alt - ), - patrol_start=start, - patrol_end=end, - land=builder.land(flight.arrival), - divert=builder.divert(flight.divert), - bullseye=builder.bullseye(), - ) - - def generate_sweep(self, flight: Flight) -> SweepFlightPlan: - """Generate a FighterSweep flight at a given location. - - Args: - flight: The flight to generate the flight plan for. - """ - assert self.package.waypoints is not None - target = self.package.target.position - heading = Heading.from_degrees( - self.package.waypoints.join.heading_between_point(target) - ) - start_pos = target.point_from_heading( - heading.degrees, -self.doctrine.sweep_distance.meters - ) - - builder = WaypointBuilder(flight, self.coalition) - start, end = builder.sweep(start_pos, target, self.doctrine.ingress_altitude) - - hold = builder.hold(self._hold_point(flight)) - - refuel = None - - if self.package.waypoints is not None: - refuel = builder.refuel(self.package.waypoints.refuel) - - return SweepFlightPlan( - package=self.package, - flight=flight, - lead_time=timedelta(minutes=5), - takeoff=builder.takeoff(flight.departure), - hold=hold, - hold_duration=timedelta(minutes=5), - nav_to=builder.nav_path( - hold.position, start.position, self.doctrine.ingress_altitude - ), - nav_from=builder.nav_path( - end.position, flight.arrival.position, self.doctrine.ingress_altitude - ), - sweep_start=start, - sweep_end=end, - refuel=refuel, - land=builder.land(flight.arrival), - divert=builder.divert(flight.divert), - bullseye=builder.bullseye(), - ) - - def generate_transport(self, flight: Flight) -> AirliftFlightPlan: - """Generate an airlift flight at a given location. - - Args: - flight: The flight to generate the flight plan for. - """ - cargo = flight.cargo - if cargo is None: - raise PlanningError( - "Cannot plan transport mission for flight with no cargo." - ) - - altitude = feet(1500) - altitude_is_agl = True - - builder = WaypointBuilder(flight, self.coalition) - - pickup = None - nav_to_pickup = [] - if cargo.origin != flight.departure: - pickup = builder.pickup(cargo.origin) - nav_to_pickup = builder.nav_path( - flight.departure.position, - cargo.origin.position, - altitude, - altitude_is_agl, - ) - - return AirliftFlightPlan( - package=self.package, - flight=flight, - takeoff=builder.takeoff(flight.departure), - nav_to_pickup=nav_to_pickup, - pickup=pickup, - nav_to_drop_off=builder.nav_path( - cargo.origin.position, - cargo.next_stop.position, - altitude, - altitude_is_agl, - ), - drop_off=builder.drop_off(cargo.next_stop), - nav_to_home=builder.nav_path( - cargo.origin.position, - flight.arrival.position, - altitude, - altitude_is_agl, - ), - land=builder.land(flight.arrival), - divert=builder.divert(flight.divert), - bullseye=builder.bullseye(), - ) - - def generate_ferry(self, flight: Flight) -> FerryFlightPlan: - """Generate a ferry flight at a given location. - - Args: - flight: The flight to generate the flight plan for. - """ - - if flight.departure == flight.arrival: - raise PlanningError( - f"Cannot plan ferry flight: departure and arrival are both " - f"{flight.departure}" - ) - - altitude_is_agl = flight.unit_type.dcs_unit_type.helicopter - altitude = ( - feet(1500) - if altitude_is_agl - else flight.unit_type.preferred_patrol_altitude - ) - - builder = WaypointBuilder(flight, self.coalition) - return FerryFlightPlan( - package=self.package, - flight=flight, - takeoff=builder.takeoff(flight.departure), - nav_to_destination=builder.nav_path( - flight.departure.position, - flight.arrival.position, - altitude, - altitude_is_agl, - ), - land=builder.land(flight.arrival), - divert=builder.divert(flight.divert), - bullseye=builder.bullseye(), - ) - - def cap_racetrack_for_objective( - self, location: MissionTarget, barcap: bool - ) -> Tuple[Point, Point]: - closest_cache = ObjectiveDistanceCache.get_closest_airfields(location) - for airfield in closest_cache.operational_airfields: - # If the mission is a BARCAP of an enemy airfield, find the *next* - # closest enemy airfield. - if airfield == self.package.target: - continue - if airfield.captured != self.is_player: - closest_airfield = airfield - break - else: - raise PlanningError("Could not find any enemy airfields") - - heading = Heading.from_degrees( - location.position.heading_between_point(closest_airfield.position) - ) - - position = ShapelyPoint( - self.package.target.position.x, self.package.target.position.y - ) - - if barcap: - # BARCAPs should remain far enough back from the enemy that their - # commit range does not enter the enemy's threat zone. Include a 5nm - # buffer. - distance_to_no_fly = ( - meters(position.distance(self.threat_zones.all)) - - self.doctrine.cap_engagement_range - - nautical_miles(5) - ) - max_track_length = self.doctrine.cap_max_track_length - else: - # Other race tracks (TARCAPs, currently) just try to keep some - # distance from the nearest enemy airbase, but since they are by - # definition in enemy territory they can't avoid the threat zone - # without being useless. - min_distance_from_enemy = nautical_miles(20) - distance_to_airfield = meters( - closest_airfield.position.distance_to_point( - self.package.target.position - ) - ) - distance_to_no_fly = distance_to_airfield - min_distance_from_enemy - - # TARCAPs fly short racetracks because they need to react faster. - max_track_length = self.doctrine.cap_min_track_length + 0.3 * ( - self.doctrine.cap_max_track_length - self.doctrine.cap_min_track_length - ) - - min_cap_distance = min( - self.doctrine.cap_min_distance_from_cp, distance_to_no_fly - ) - max_cap_distance = min( - self.doctrine.cap_max_distance_from_cp, distance_to_no_fly - ) - - end = location.position.point_from_heading( - heading.degrees, - random.randint(int(min_cap_distance.meters), int(max_cap_distance.meters)), - ) - - track_length = random.randint( - int(self.doctrine.cap_min_track_length.meters), - int(max_track_length.meters), - ) - start = end.point_from_heading(heading.opposite.degrees, track_length) - return start, end - - def aewc_orbit(self, location: MissionTarget) -> Point: - closest_boundary = self.threat_zones.closest_boundary(location.position) - heading_to_threat_boundary = Heading.from_degrees( - location.position.heading_between_point(closest_boundary) - ) - distance_to_threat = meters( - location.position.distance_to_point(closest_boundary) - ) - orbit_heading = heading_to_threat_boundary - # Station 100nm outside the threat zone. - threat_buffer = nautical_miles(100) - if self.threat_zones.threatened(location.position): - orbit_distance = distance_to_threat + threat_buffer - else: - orbit_distance = distance_to_threat - threat_buffer - - return location.position.point_from_heading( - orbit_heading.degrees, orbit_distance.meters - ) - - def generate_tarcap(self, flight: Flight) -> TarCapFlightPlan: - """Generate a CAP flight plan for the given front line. - - Args: - flight: The flight to generate the flight plan for. - """ - location = self.package.target - - preferred_alt = flight.unit_type.preferred_patrol_altitude - randomized_alt = preferred_alt + feet(random.randint(-2, 1) * 1000) - patrol_alt = max( - self.doctrine.min_patrol_altitude, - min(self.doctrine.max_patrol_altitude, randomized_alt), - ) - patrol_speed = flight.unit_type.preferred_patrol_speed(patrol_alt) - logging.debug( - f"TARCAP patrol speed for {flight.unit_type.name} at {patrol_alt.feet}ft: {patrol_speed.knots} KTAS" - ) - - # Create points - builder = WaypointBuilder(flight, self.coalition) - orbit0p, orbit1p = self.cap_racetrack_for_objective(location, barcap=False) - - start, end = builder.race_track(orbit0p, orbit1p, patrol_alt) - - refuel = None - - if self.package.waypoints is not None: - refuel = builder.refuel(self.package.waypoints.refuel) - - return TarCapFlightPlan( - package=self.package, - flight=flight, - lead_time=timedelta(minutes=2), - # Note that this duration only has an effect if there are no - # flights in the package that have requested escort. If the package - # requests an escort the CAP flight will remain on station for the - # duration of the escorted mission, or until it is winchester/bingo. - patrol_duration=self.doctrine.cap_duration, - patrol_speed=patrol_speed, - engagement_distance=self.doctrine.cap_engagement_range, - takeoff=builder.takeoff(flight.departure), - nav_to=builder.nav_path(flight.departure.position, orbit0p, patrol_alt), - nav_from=builder.nav_path(orbit1p, flight.arrival.position, patrol_alt), - patrol_start=start, - patrol_end=end, - refuel=refuel, - land=builder.land(flight.arrival), - divert=builder.divert(flight.divert), - bullseye=builder.bullseye(), - ) - - def generate_dead( - self, flight: Flight, custom_targets: Optional[List[TheaterUnit]] - ) -> StrikeFlightPlan: - """Generate a DEAD flight at a given location. - - Args: - flight: The flight to generate the flight plan for. - custom_targets: Specific radar equipped units selected by the user. - """ - location = self.package.target - - is_ewr = isinstance(location, EwrGroundObject) - is_sam = isinstance(location, SamGroundObject) - if not is_ewr and not is_sam: - logging.exception( - f"Invalid Objective Location for DEAD flight {flight=} at {location=}" - ) - raise InvalidObjectiveLocation(flight.flight_type, location) - - # TODO: Unify these. - # There doesn't seem to be any reason to treat the UI fragged missions - # different from the automatic missions. - targets: Optional[List[StrikeTarget]] = None - if custom_targets is not None: - targets = [] - for target in custom_targets: - targets.append(StrikeTarget(location.name, target)) - - return self.strike_flightplan( - flight, location, FlightWaypointType.INGRESS_DEAD, targets - ) - - def generate_oca_strike(self, flight: Flight) -> StrikeFlightPlan: - """Generate an OCA Strike flight plan at a given location. - - Args: - flight: The flight to generate the flight plan for. - """ - location = self.package.target - - if not isinstance(location, Airfield): - logging.exception( - f"Invalid Objective Location for OCA Strike flight " - f"{flight=} at {location=}." - ) - raise InvalidObjectiveLocation(flight.flight_type, location) - - return self.strike_flightplan( - flight, location, FlightWaypointType.INGRESS_OCA_AIRCRAFT - ) - - def generate_runway_attack(self, flight: Flight) -> StrikeFlightPlan: - """Generate a runway attack flight plan at a given location. - - Args: - flight: The flight to generate the flight plan for. - """ - location = self.package.target - - if not isinstance(location, Airfield): - logging.exception( - f"Invalid Objective Location for runway bombing flight " - f"{flight=} at {location=}." - ) - raise InvalidObjectiveLocation(flight.flight_type, location) - - return self.strike_flightplan( - flight, location, FlightWaypointType.INGRESS_OCA_RUNWAY - ) - - def generate_sead( - self, flight: Flight, custom_targets: Optional[List[TheaterUnit]] - ) -> StrikeFlightPlan: - """Generate a SEAD flight at a given location. - - Args: - flight: The flight to generate the flight plan for. - custom_targets: Specific radar equipped units selected by the user. - """ - location = self.package.target - - # TODO: Unify these. - # There doesn't seem to be any reason to treat the UI fragged missions - # different from the automatic missions. - targets: Optional[List[StrikeTarget]] = None - if custom_targets is not None: - targets = [] - for target in custom_targets: - targets.append(StrikeTarget(location.name, target)) - - return self.strike_flightplan( - flight, - location, - FlightWaypointType.INGRESS_SEAD, - targets, - lead_time=timedelta(minutes=1), - ) - - def generate_escort(self, flight: Flight) -> StrikeFlightPlan: - assert self.package.waypoints is not None - - builder = WaypointBuilder(flight, self.coalition) - ingress, target = builder.escort( - self.package.waypoints.ingress, self.package.target - ) - hold = builder.hold(self._hold_point(flight)) - join = builder.join(self.package.waypoints.join) - split = builder.split(self.package.waypoints.split) - refuel = None - if self.package.waypoints.refuel is not None: - refuel = builder.refuel(self.package.waypoints.refuel) - - return StrikeFlightPlan( - package=self.package, - flight=flight, - takeoff=builder.takeoff(flight.departure), - hold=hold, - hold_duration=timedelta(minutes=5), - nav_to=builder.nav_path( - hold.position, join.position, self.doctrine.ingress_altitude - ), - join=join, - ingress=ingress, - targets=[target], - split=split, - refuel=refuel, - nav_from=builder.nav_path( - split.position, flight.arrival.position, self.doctrine.ingress_altitude - ), - land=builder.land(flight.arrival), - divert=builder.divert(flight.divert), - bullseye=builder.bullseye(), - ) - - def generate_cas(self, flight: Flight) -> CasFlightPlan: - """Generate a CAS flight plan for the given target. - - Args: - flight: The flight to generate the flight plan for. - """ - location = self.package.target - - if not isinstance(location, FrontLine): - raise InvalidObjectiveLocation(flight.flight_type, location) - - from game.missiongenerator.frontlineconflictdescription import ( - FrontLineConflictDescription, - ) - - ingress, heading, distance = FrontLineConflictDescription.frontline_vector( - location, self.theater - ) - center = ingress.point_from_heading(heading.degrees, distance / 2) - egress = ingress.point_from_heading(heading.degrees, distance) - - ingress_distance = ingress.distance_to_point(flight.departure.position) - egress_distance = egress.distance_to_point(flight.departure.position) - if egress_distance < ingress_distance: - ingress, egress = egress, ingress - - builder = WaypointBuilder(flight, self.coalition) - - # 2021-08-02: patrol_speed will currently have no effect because - # CAS doesn't use OrbitAction. But all PatrollingFlightPlan are expected - # to have patrol_speed - is_helo = flight.unit_type.dcs_unit_type.helicopter - ingress_egress_altitude = ( - self.doctrine.ingress_altitude if not is_helo else meters(50) - ) - patrol_speed = flight.unit_type.preferred_patrol_speed(ingress_egress_altitude) - use_agl_ingress_egress = is_helo - - from game.missiongenerator.frontlineconflictdescription import FRONTLINE_LENGTH - - return CasFlightPlan( - package=self.package, - flight=flight, - patrol_duration=self.doctrine.cas_duration, - patrol_speed=patrol_speed, - takeoff=builder.takeoff(flight.departure), - nav_to=builder.nav_path( - flight.departure.position, - ingress, - ingress_egress_altitude, - use_agl_ingress_egress, - ), - nav_from=builder.nav_path( - egress, - flight.arrival.position, - ingress_egress_altitude, - use_agl_ingress_egress, - ), - patrol_start=builder.ingress( - FlightWaypointType.INGRESS_CAS, ingress, location - ), - engagement_distance=meters(FRONTLINE_LENGTH) / 2, - target=builder.cas(center), - patrol_end=builder.egress(egress, location), - land=builder.land(flight.arrival), - divert=builder.divert(flight.divert), - bullseye=builder.bullseye(), - ) - - def generate_refueling_racetrack(self, flight: Flight) -> RefuelingFlightPlan: - - racetrack_half_distance = Distance.from_nautical_miles(20).meters - - patrol_duration = timedelta(hours=1) - - location = self.package.target - - closest_boundary = self.threat_zones.closest_boundary(location.position) - heading_to_threat_boundary = Heading.from_degrees( - location.position.heading_between_point(closest_boundary) - ) - distance_to_threat = meters( - location.position.distance_to_point(closest_boundary) - ) - orbit_heading = heading_to_threat_boundary - - # Station 70nm outside the threat zone. - threat_buffer = nautical_miles(70) - if self.threat_zones.threatened(location.position): - orbit_distance = distance_to_threat + threat_buffer - else: - orbit_distance = distance_to_threat - threat_buffer - - racetrack_center = location.position.point_from_heading( - orbit_heading.degrees, orbit_distance.meters - ) - - racetrack_start = racetrack_center.point_from_heading( - orbit_heading.right.degrees, racetrack_half_distance - ) - - racetrack_end = racetrack_center.point_from_heading( - orbit_heading.left.degrees, racetrack_half_distance - ) - - builder = WaypointBuilder(flight, self.coalition) - - tanker_type = flight.unit_type - if tanker_type.patrol_altitude is not None: - altitude = tanker_type.patrol_altitude - else: - altitude = feet(21000) - - # TODO: Could use flight.unit_type.preferred_patrol_speed(altitude) instead. - if tanker_type.patrol_speed is not None: - speed = tanker_type.patrol_speed - else: - # ~280 knots IAS at 21000. - speed = knots(400) - - racetrack = builder.race_track(racetrack_start, racetrack_end, altitude) - - return RefuelingFlightPlan( - package=self.package, - flight=flight, - takeoff=builder.takeoff(flight.departure), - nav_to=builder.nav_path( - flight.departure.position, racetrack_start, altitude - ), - nav_from=builder.nav_path(racetrack_end, flight.arrival.position, altitude), - patrol_start=racetrack[0], - patrol_end=racetrack[1], - land=builder.land(flight.arrival), - divert=builder.divert(flight.divert), - bullseye=builder.bullseye(), - patrol_duration=patrol_duration, - patrol_speed=speed, - # TODO: Factor out a common base of the combat and non-combat race-tracks. - # No harm in setting this, but we ought to clean up a bit. - engagement_distance=meters(0), - ) - - def generate_refueling_package_support( - self, flight: Flight - ) -> PackageRefuelingFlightPlan: - package_waypoints = self.package.waypoints - assert package_waypoints is not None - - racetrack_half_distance = Distance.from_nautical_miles(20).meters - # TODO: Only consider aircraft that can refuel with this tanker type. - refuel_time_minutes = 5 - for flight in self.package.flights: - flight_size = flight.roster.max_size - refuel_time_minutes = refuel_time_minutes + 4 * flight_size + 1 - - patrol_duration = timedelta(minutes=refuel_time_minutes) - - racetrack_center = package_waypoints.refuel - - split_heading = Heading.from_degrees( - racetrack_center.heading_between_point(package_waypoints.split) - ) - home_heading = split_heading.opposite - - racetrack_start = racetrack_center.point_from_heading( - split_heading.degrees, racetrack_half_distance - ) - - racetrack_end = racetrack_center.point_from_heading( - home_heading.degrees, racetrack_half_distance - ) - - builder = WaypointBuilder(flight, self.coalition) - - tanker_type = flight.unit_type - if tanker_type.patrol_altitude is not None: - altitude = tanker_type.patrol_altitude - else: - altitude = feet(21000) - - # TODO: Could use flight.unit_type.preferred_patrol_speed(altitude) instead. - if tanker_type.patrol_speed is not None: - speed = tanker_type.patrol_speed - else: - # ~280 knots IAS at 21000. - speed = knots(400) - - racetrack = builder.race_track(racetrack_start, racetrack_end, altitude) - - return PackageRefuelingFlightPlan( - package=self.package, - flight=flight, - takeoff=builder.takeoff(flight.departure), - nav_to=builder.nav_path( - flight.departure.position, racetrack_start, altitude - ), - nav_from=builder.nav_path(racetrack_end, flight.arrival.position, altitude), - patrol_start=racetrack[0], - patrol_end=racetrack[1], - land=builder.land(flight.arrival), - divert=builder.divert(flight.divert), - bullseye=builder.bullseye(), - patrol_duration=patrol_duration, - patrol_speed=speed, - # TODO: Factor out a common base of the combat and non-combat race-tracks. - # No harm in setting this, but we ought to clean up a bit. - engagement_distance=meters(0), - ) - - @staticmethod - def target_waypoint( - flight: Flight, builder: WaypointBuilder, target: StrikeTarget - ) -> FlightWaypoint: - if flight.flight_type in {FlightType.ANTISHIP, FlightType.BAI}: - return builder.bai_group(target) - elif flight.flight_type == FlightType.DEAD: - return builder.dead_point(target) - elif flight.flight_type == FlightType.SEAD: - return builder.sead_point(target) - else: - return builder.strike_point(target) - - @staticmethod - def target_area_waypoint( - flight: Flight, location: MissionTarget, builder: WaypointBuilder - ) -> FlightWaypoint: - if flight.flight_type == FlightType.DEAD: - return builder.dead_area(location) - elif flight.flight_type == FlightType.SEAD: - return builder.sead_area(location) - elif flight.flight_type == FlightType.OCA_AIRCRAFT: - return builder.oca_strike_area(location) - else: - return builder.strike_area(location) - - def _hold_point(self, flight: Flight) -> Point: - assert self.package.waypoints is not None - origin = flight.departure.position - target = self.package.target.position - join = self.package.waypoints.join - ip = self.package.waypoints.ingress - return HoldZoneGeometry( - target, origin, ip, join, self.coalition, self.theater - ).find_best_hold_point() - - # TODO: Make a model for the waypoint builder and use that in the UI. - def generate_rtb_waypoint( - self, flight: Flight, arrival: ControlPoint - ) -> FlightWaypoint: - """Generate RTB landing point. - - Args: - flight: The flight to generate the landing waypoint for. - arrival: Arrival airfield or carrier. - """ - builder = WaypointBuilder(flight, self.coalition) - return builder.land(arrival) - - def strike_flightplan( - self, - flight: Flight, - location: MissionTarget, - ingress_type: FlightWaypointType, - targets: Optional[List[StrikeTarget]] = None, - lead_time: timedelta = timedelta(), - ) -> StrikeFlightPlan: - assert self.package.waypoints is not None - builder = WaypointBuilder(flight, self.coalition, targets) - - target_waypoints: List[FlightWaypoint] = [] - if targets is not None: - for target in targets: - target_waypoints.append(self.target_waypoint(flight, builder, target)) - else: - target_waypoints.append( - self.target_area_waypoint(flight, location, builder) - ) - - hold = builder.hold(self._hold_point(flight)) - join = builder.join(self.package.waypoints.join) - split = builder.split(self.package.waypoints.split) - refuel = None - if self.package.waypoints.refuel is not None: - refuel = builder.refuel(self.package.waypoints.refuel) - - return StrikeFlightPlan( - package=self.package, - flight=flight, - takeoff=builder.takeoff(flight.departure), - hold=hold, - hold_duration=timedelta(minutes=5), - nav_to=builder.nav_path( - hold.position, join.position, self.doctrine.ingress_altitude - ), - join=join, - ingress=builder.ingress( - ingress_type, self.package.waypoints.ingress, location - ), - targets=target_waypoints, - split=split, - refuel=refuel, - nav_from=builder.nav_path( - split.position, flight.arrival.position, self.doctrine.ingress_altitude - ), - land=builder.land(flight.arrival), - divert=builder.divert(flight.divert), - bullseye=builder.bullseye(), - lead_time=lead_time, - ) - - def package_airfield(self) -> ControlPoint: - # We'll always have a package, but if this is being planned via the UI - # it could be the first flight in the package. - if not self.package.flights: - raise PlanningError( - "Cannot determine source airfield for package with no flights" - ) - - # The package airfield is either the flight's airfield (when there is no - # package) or the closest airfield to the objective that is the - # departure airfield for some flight in the package. - cache = ObjectiveDistanceCache.get_closest_airfields(self.package.target) - for airfield in cache.operational_airfields: - for flight in self.package.flights: - if flight.departure == airfield: - return airfield - raise PlanningError("Could not find any airfield assigned to this package") diff --git a/game/ato/flightplans/__init__.py b/game/ato/flightplans/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/game/ato/flightplans/aewc.py b/game/ato/flightplans/aewc.py new file mode 100644 index 00000000..82b9b659 --- /dev/null +++ b/game/ato/flightplans/aewc.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from datetime import timedelta +from typing import Type + +from game.ato.flightplans.ibuilder import IBuilder +from game.ato.flightplans.patrolling import PatrollingFlightPlan +from game.ato.flightplans.waypointbuilder import WaypointBuilder +from game.utils import Heading, feet, knots, meters, nautical_miles + + +class Builder(IBuilder): + def build(self) -> AewcFlightPlan: + racetrack_half_distance = nautical_miles(30).meters + + patrol_duration = timedelta(hours=4) + + location = self.package.target + + closest_boundary = self.threat_zones.closest_boundary(location.position) + heading_to_threat_boundary = Heading.from_degrees( + location.position.heading_between_point(closest_boundary) + ) + distance_to_threat = meters( + location.position.distance_to_point(closest_boundary) + ) + orbit_heading = heading_to_threat_boundary + + # Station 80nm outside the threat zone. + threat_buffer = nautical_miles(80) + if self.threat_zones.threatened(location.position): + orbit_distance = distance_to_threat + threat_buffer + else: + orbit_distance = distance_to_threat - threat_buffer + + racetrack_center = location.position.point_from_heading( + orbit_heading.degrees, orbit_distance.meters + ) + + racetrack_start = racetrack_center.point_from_heading( + orbit_heading.right.degrees, racetrack_half_distance + ) + + racetrack_end = racetrack_center.point_from_heading( + orbit_heading.left.degrees, racetrack_half_distance + ) + + builder = WaypointBuilder(self.flight, self.coalition) + + if self.flight.unit_type.patrol_altitude is not None: + altitude = self.flight.unit_type.patrol_altitude + else: + altitude = feet(25000) + + if self.flight.unit_type.preferred_patrol_speed(altitude) is not None: + speed = self.flight.unit_type.preferred_patrol_speed(altitude) + else: + speed = knots(390) + + racetrack = builder.race_track(racetrack_start, racetrack_end, altitude) + + return AewcFlightPlan( + flight=self.flight, + departure=builder.takeoff(self.flight.departure), + nav_to=builder.nav_path( + self.flight.departure.position, racetrack_start, altitude + ), + nav_from=builder.nav_path( + racetrack_end, self.flight.arrival.position, altitude + ), + patrol_start=racetrack[0], + patrol_end=racetrack[1], + arrival=builder.land(self.flight.arrival), + divert=builder.divert(self.flight.divert), + bullseye=builder.bullseye(), + patrol_duration=patrol_duration, + patrol_speed=speed, + # TODO: Factor out a common base of the combat and non-combat race-tracks. + # No harm in setting this, but we ought to clean up a bit. + engagement_distance=meters(0), + ) + + +class AewcFlightPlan(PatrollingFlightPlan): + @staticmethod + def builder_type() -> Type[IBuilder]: + return Builder diff --git a/game/ato/flightplans/airlift.py b/game/ato/flightplans/airlift.py new file mode 100644 index 00000000..7b2a7eed --- /dev/null +++ b/game/ato/flightplans/airlift.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +from collections.abc import Iterator +from datetime import timedelta +from typing import TYPE_CHECKING, Type + +from game.utils import feet +from .ibuilder import IBuilder +from .planningerror import PlanningError +from .standard import StandardFlightPlan +from .waypointbuilder import WaypointBuilder + +if TYPE_CHECKING: + from ..flight import Flight + from ..flightwaypoint import FlightWaypoint + + +class Builder(IBuilder): + def build(self) -> AirliftFlightPlan: + cargo = self.flight.cargo + if cargo is None: + raise PlanningError( + "Cannot plan transport mission for flight with no cargo." + ) + + altitude = feet(1500) + altitude_is_agl = True + + builder = WaypointBuilder(self.flight, self.coalition) + + pickup = None + nav_to_pickup = [] + if cargo.origin != self.flight.departure: + pickup = builder.pickup(cargo.origin) + nav_to_pickup = builder.nav_path( + self.flight.departure.position, + cargo.origin.position, + altitude, + altitude_is_agl, + ) + + return AirliftFlightPlan( + flight=self.flight, + departure=builder.takeoff(self.flight.departure), + nav_to_pickup=nav_to_pickup, + pickup=pickup, + nav_to_drop_off=builder.nav_path( + cargo.origin.position, + cargo.next_stop.position, + altitude, + altitude_is_agl, + ), + drop_off=builder.drop_off(cargo.next_stop), + nav_to_home=builder.nav_path( + cargo.origin.position, + self.flight.arrival.position, + altitude, + altitude_is_agl, + ), + arrival=builder.land(self.flight.arrival), + divert=builder.divert(self.flight.divert), + bullseye=builder.bullseye(), + ) + + +class AirliftFlightPlan(StandardFlightPlan): + def __init__( + self, + flight: Flight, + departure: FlightWaypoint, + nav_to_pickup: list[FlightWaypoint], + pickup: FlightWaypoint | None, + nav_to_drop_off: list[FlightWaypoint], + drop_off: FlightWaypoint, + nav_to_home: list[FlightWaypoint], + arrival: FlightWaypoint, + divert: FlightWaypoint | None, + bullseye: FlightWaypoint, + ) -> None: + super().__init__(flight, departure, arrival, divert, bullseye) + self.nav_to_pickup = nav_to_pickup + self.pickup = pickup + self.nav_to_drop_off = nav_to_drop_off + self.drop_off = drop_off + self.nav_to_home = nav_to_home + + @staticmethod + def builder_type() -> Type[Builder]: + return Builder + + def iter_waypoints(self) -> Iterator[FlightWaypoint]: + yield self.departure + yield from self.nav_to_pickup + if self.pickup: + yield self.pickup + yield from self.nav_to_drop_off + yield self.drop_off + yield from self.nav_to_home + yield self.arrival + if self.divert is not None: + yield self.divert + yield self.bullseye + + @property + def tot_waypoint(self) -> FlightWaypoint | None: + return self.drop_off + + def tot_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None: + # TOT planning isn't really useful for transports. They're behind the front + # lines so no need to wait for escorts or for other missions to complete. + return None + + def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None: + return None + + @property + def mission_departure_time(self) -> timedelta: + return self.package.time_over_target diff --git a/game/ato/flightplans/antiship.py b/game/ato/flightplans/antiship.py new file mode 100644 index 00000000..505ba322 --- /dev/null +++ b/game/ato/flightplans/antiship.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from typing import Type + +from game.theater import NavalControlPoint +from game.theater.theatergroundobject import NavalGroundObject +from .formationattack import FormationAttackBuilder, FormationAttackFlightPlan +from .invalidobjectivelocation import InvalidObjectiveLocation +from .waypointbuilder import StrikeTarget +from ..flightwaypointtype import FlightWaypointType + + +class AntiShipFlightPlan(FormationAttackFlightPlan): + @staticmethod + def builder_type() -> Type[Builder]: + return Builder + + +class Builder(FormationAttackBuilder[AntiShipFlightPlan]): + def build(self) -> FormationAttackFlightPlan: + location = self.package.target + + from game.transfers import CargoShip + + if isinstance(location, NavalControlPoint): + targets = self.anti_ship_targets_for_tgo(location.find_main_tgo()) + elif isinstance(location, NavalGroundObject): + targets = self.anti_ship_targets_for_tgo(location) + elif isinstance(location, CargoShip): + targets = [StrikeTarget(location.name, location)] + else: + raise InvalidObjectiveLocation(self.flight.flight_type, location) + + return self._build(AntiShipFlightPlan, FlightWaypointType.INGRESS_BAI, targets) + + @staticmethod + def anti_ship_targets_for_tgo(tgo: NavalGroundObject) -> list[StrikeTarget]: + return [StrikeTarget(f"{g.group_name} at {tgo.name}", g) for g in tgo.groups] diff --git a/game/ato/flightplans/bai.py b/game/ato/flightplans/bai.py new file mode 100644 index 00000000..9fb164c6 --- /dev/null +++ b/game/ato/flightplans/bai.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from typing import Type + +from game.theater.theatergroundobject import TheaterGroundObject +from .formationattack import FormationAttackBuilder, FormationAttackFlightPlan +from .invalidobjectivelocation import InvalidObjectiveLocation +from .waypointbuilder import StrikeTarget +from ..flightwaypointtype import FlightWaypointType + + +class BaiFlightPlan(FormationAttackFlightPlan): + @staticmethod + def builder_type() -> Type[Builder]: + return Builder + + +class Builder(FormationAttackBuilder[BaiFlightPlan]): + def build(self) -> FormationAttackFlightPlan: + location = self.package.target + + from game.transfers import Convoy + + targets: list[StrikeTarget] = [] + if isinstance(location, TheaterGroundObject): + for group in location.groups: + if group.units: + targets.append( + StrikeTarget(f"{group.group_name} at {location.name}", group) + ) + elif isinstance(location, Convoy): + targets.append(StrikeTarget(location.name, location)) + else: + raise InvalidObjectiveLocation(self.flight.flight_type, location) + + return self._build(BaiFlightPlan, FlightWaypointType.INGRESS_BAI, targets) diff --git a/game/ato/flightplans/barcap.py b/game/ato/flightplans/barcap.py new file mode 100644 index 00000000..76ea3064 --- /dev/null +++ b/game/ato/flightplans/barcap.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import random +from typing import Type + +from game.theater import FrontLine +from game.utils import feet +from .capbuilder import CapBuilder +from .invalidobjectivelocation import InvalidObjectiveLocation +from .patrolling import PatrollingFlightPlan +from .waypointbuilder import WaypointBuilder + + +class Builder(CapBuilder): + def build(self) -> BarCapFlightPlan: + location = self.package.target + + if isinstance(location, FrontLine): + raise InvalidObjectiveLocation(self.flight.flight_type, location) + + start_pos, end_pos = self.cap_racetrack_for_objective(location, barcap=True) + + preferred_alt = self.flight.unit_type.preferred_patrol_altitude + randomized_alt = preferred_alt + feet(random.randint(-2, 1) * 1000) + patrol_alt = max( + self.doctrine.min_patrol_altitude, + min(self.doctrine.max_patrol_altitude, randomized_alt), + ) + + patrol_speed = self.flight.unit_type.preferred_patrol_speed(patrol_alt) + + builder = WaypointBuilder(self.flight, self.coalition) + start, end = builder.race_track(start_pos, end_pos, patrol_alt) + + return BarCapFlightPlan( + flight=self.flight, + patrol_duration=self.doctrine.cap_duration, + patrol_speed=patrol_speed, + engagement_distance=self.doctrine.cap_engagement_range, + departure=builder.takeoff(self.flight.departure), + nav_to=builder.nav_path( + self.flight.departure.position, start.position, patrol_alt + ), + nav_from=builder.nav_path( + end.position, self.flight.arrival.position, patrol_alt + ), + patrol_start=start, + patrol_end=end, + arrival=builder.land(self.flight.arrival), + divert=builder.divert(self.flight.divert), + bullseye=builder.bullseye(), + ) + + +class BarCapFlightPlan(PatrollingFlightPlan): + @staticmethod + def builder_type() -> Type[Builder]: + return Builder diff --git a/game/ato/flightplans/capbuilder.py b/game/ato/flightplans/capbuilder.py new file mode 100644 index 00000000..75fe0a64 --- /dev/null +++ b/game/ato/flightplans/capbuilder.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +import random +from abc import ABC +from typing import TYPE_CHECKING + +from dcs import Point +from shapely.geometry import Point as ShapelyPoint + +from game.utils import Heading, meters, nautical_miles +from ..closestairfields import ObjectiveDistanceCache +from ..flightplans.ibuilder import IBuilder +from ..flightplans.planningerror import PlanningError + +if TYPE_CHECKING: + from game.theater import MissionTarget + + +class CapBuilder(IBuilder, ABC): + def cap_racetrack_for_objective( + self, location: MissionTarget, barcap: bool + ) -> tuple[Point, Point]: + closest_cache = ObjectiveDistanceCache.get_closest_airfields(location) + for airfield in closest_cache.operational_airfields: + # If the mission is a BARCAP of an enemy airfield, find the *next* + # closest enemy airfield. + if airfield == self.package.target: + continue + if airfield.captured != self.is_player: + closest_airfield = airfield + break + else: + raise PlanningError("Could not find any enemy airfields") + + heading = Heading.from_degrees( + location.position.heading_between_point(closest_airfield.position) + ) + + position = ShapelyPoint( + self.package.target.position.x, self.package.target.position.y + ) + + if barcap: + # BARCAPs should remain far enough back from the enemy that their + # commit range does not enter the enemy's threat zone. Include a 5nm + # buffer. + distance_to_no_fly = ( + meters(position.distance(self.threat_zones.all)) + - self.doctrine.cap_engagement_range + - nautical_miles(5) + ) + max_track_length = self.doctrine.cap_max_track_length + else: + # Other race tracks (TARCAPs, currently) just try to keep some + # distance from the nearest enemy airbase, but since they are by + # definition in enemy territory they can't avoid the threat zone + # without being useless. + min_distance_from_enemy = nautical_miles(20) + distance_to_airfield = meters( + closest_airfield.position.distance_to_point( + self.package.target.position + ) + ) + distance_to_no_fly = distance_to_airfield - min_distance_from_enemy + + # TARCAPs fly short racetracks because they need to react faster. + max_track_length = self.doctrine.cap_min_track_length + 0.3 * ( + self.doctrine.cap_max_track_length - self.doctrine.cap_min_track_length + ) + + min_cap_distance = min( + self.doctrine.cap_min_distance_from_cp, distance_to_no_fly + ) + max_cap_distance = min( + self.doctrine.cap_max_distance_from_cp, distance_to_no_fly + ) + + end = location.position.point_from_heading( + heading.degrees, + random.randint(int(min_cap_distance.meters), int(max_cap_distance.meters)), + ) + + track_length = random.randint( + int(self.doctrine.cap_min_track_length.meters), + int(max_track_length.meters), + ) + start = end.point_from_heading(heading.opposite.degrees, track_length) + return start, end diff --git a/game/ato/flightplans/cas.py b/game/ato/flightplans/cas.py new file mode 100644 index 00000000..608b81d1 --- /dev/null +++ b/game/ato/flightplans/cas.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +from collections.abc import Iterator +from datetime import timedelta +from typing import TYPE_CHECKING, Type + +from game.theater import FrontLine +from game.utils import Distance, Speed, meters +from .ibuilder import IBuilder +from .invalidobjectivelocation import InvalidObjectiveLocation +from .patrolling import PatrollingFlightPlan +from .waypointbuilder import WaypointBuilder +from ..flightwaypointtype import FlightWaypointType + +if TYPE_CHECKING: + from ..flight import Flight + from ..flightwaypoint import FlightWaypoint + + +class Builder(IBuilder): + def build(self) -> CasFlightPlan: + location = self.package.target + + if not isinstance(location, FrontLine): + raise InvalidObjectiveLocation(self.flight.flight_type, location) + + from game.missiongenerator.frontlineconflictdescription import ( + FrontLineConflictDescription, + ) + + ingress, heading, distance = FrontLineConflictDescription.frontline_vector( + location, self.theater + ) + center = ingress.point_from_heading(heading.degrees, distance / 2) + egress = ingress.point_from_heading(heading.degrees, distance) + + ingress_distance = ingress.distance_to_point(self.flight.departure.position) + egress_distance = egress.distance_to_point(self.flight.departure.position) + if egress_distance < ingress_distance: + ingress, egress = egress, ingress + + builder = WaypointBuilder(self.flight, self.coalition) + + # 2021-08-02: patrol_speed will currently have no effect because + # CAS doesn't use OrbitAction. But all PatrollingFlightPlan are expected + # to have patrol_speed + is_helo = self.flight.unit_type.dcs_unit_type.helicopter + ingress_egress_altitude = ( + self.doctrine.ingress_altitude if not is_helo else meters(50) + ) + patrol_speed = self.flight.unit_type.preferred_patrol_speed( + ingress_egress_altitude + ) + use_agl_ingress_egress = is_helo + + from game.missiongenerator.frontlineconflictdescription import FRONTLINE_LENGTH + + return CasFlightPlan( + flight=self.flight, + patrol_duration=self.doctrine.cas_duration, + patrol_speed=patrol_speed, + departure=builder.takeoff(self.flight.departure), + nav_to=builder.nav_path( + self.flight.departure.position, + ingress, + ingress_egress_altitude, + use_agl_ingress_egress, + ), + nav_from=builder.nav_path( + egress, + self.flight.arrival.position, + ingress_egress_altitude, + use_agl_ingress_egress, + ), + patrol_start=builder.ingress( + FlightWaypointType.INGRESS_CAS, ingress, location + ), + engagement_distance=meters(FRONTLINE_LENGTH) / 2, + target=builder.cas(center), + patrol_end=builder.egress(egress, location), + arrival=builder.land(self.flight.arrival), + divert=builder.divert(self.flight.divert), + bullseye=builder.bullseye(), + ) + + +class CasFlightPlan(PatrollingFlightPlan): + def __init__( + self, + flight: Flight, + departure: FlightWaypoint, + arrival: FlightWaypoint, + divert: FlightWaypoint | None, + bullseye: FlightWaypoint, + nav_to: list[FlightWaypoint], + nav_from: list[FlightWaypoint], + patrol_start: FlightWaypoint, + patrol_end: FlightWaypoint, + patrol_duration: timedelta, + patrol_speed: Speed, + engagement_distance: Distance, + target: FlightWaypoint, + ) -> None: + super().__init__( + flight, + departure, + arrival, + divert, + bullseye, + nav_to, + nav_from, + patrol_start, + patrol_end, + patrol_duration, + patrol_speed, + engagement_distance, + ) + self.target = target + + @staticmethod + def builder_type() -> Type[Builder]: + return Builder + + def iter_waypoints(self) -> Iterator[FlightWaypoint]: + yield self.departure + yield from self.nav_to + yield self.patrol_start + yield self.target + yield self.patrol_end + yield from self.nav_from + yield self.departure + if self.divert is not None: + yield self.divert + yield self.bullseye + + @property + def combat_speed_waypoints(self) -> set[FlightWaypoint]: + return {self.patrol_start, self.target, self.patrol_end} + + def request_escort_at(self) -> FlightWaypoint | None: + return self.patrol_start + + def dismiss_escort_at(self) -> FlightWaypoint | None: + return self.patrol_end diff --git a/game/ato/flightplans/custom.py b/game/ato/flightplans/custom.py new file mode 100644 index 00000000..8cbafbee --- /dev/null +++ b/game/ato/flightplans/custom.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from collections.abc import Iterator +from datetime import timedelta +from typing import TYPE_CHECKING, Type + +from .flightplan import FlightPlan +from .ibuilder import IBuilder +from ..flightwaypointtype import FlightWaypointType + +if TYPE_CHECKING: + from ..flight import Flight + from ..flightwaypoint import FlightWaypoint + + +class Builder(IBuilder): + def build(self) -> CustomFlightPlan: + return CustomFlightPlan(self.flight, []) + + +class CustomFlightPlan(FlightPlan): + def __init__(self, flight: Flight, waypoints: list[FlightWaypoint]) -> None: + super().__init__(flight) + self.custom_waypoints = waypoints + + @staticmethod + def builder_type() -> Type[Builder]: + return Builder + + def iter_waypoints(self) -> Iterator[FlightWaypoint]: + yield from self.custom_waypoints + + @property + def tot_waypoint(self) -> FlightWaypoint | None: + target_types = ( + FlightWaypointType.PATROL_TRACK, + FlightWaypointType.TARGET_GROUP_LOC, + FlightWaypointType.TARGET_POINT, + FlightWaypointType.TARGET_SHIP, + ) + for waypoint in self.waypoints: + if waypoint in target_types: + return waypoint + return None + + def tot_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None: + if waypoint == self.tot_waypoint: + return self.package.time_over_target + return None + + def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None: + return None + + @property + def mission_departure_time(self) -> timedelta: + return self.package.time_over_target diff --git a/game/ato/flightplans/dead.py b/game/ato/flightplans/dead.py new file mode 100644 index 00000000..c6949385 --- /dev/null +++ b/game/ato/flightplans/dead.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import logging +from typing import Type + +from game.theater.theatergroundobject import ( + EwrGroundObject, + SamGroundObject, +) +from .formationattack import FormationAttackBuilder, FormationAttackFlightPlan +from .invalidobjectivelocation import InvalidObjectiveLocation +from ..flightwaypointtype import FlightWaypointType + + +class DeadFlightPlan(FormationAttackFlightPlan): + @staticmethod + def builder_type() -> Type[Builder]: + return Builder + + +class Builder(FormationAttackBuilder[DeadFlightPlan]): + def build(self) -> FormationAttackFlightPlan: + location = self.package.target + + is_ewr = isinstance(location, EwrGroundObject) + is_sam = isinstance(location, SamGroundObject) + if not is_ewr and not is_sam: + logging.exception( + f"Invalid Objective Location for DEAD flight {self.flight=} at " + f"{location=}" + ) + raise InvalidObjectiveLocation(self.flight.flight_type, location) + + return self._build(DeadFlightPlan, FlightWaypointType.INGRESS_DEAD) diff --git a/game/ato/flightplans/escort.py b/game/ato/flightplans/escort.py new file mode 100644 index 00000000..0304fb69 --- /dev/null +++ b/game/ato/flightplans/escort.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from datetime import timedelta +from typing import Type + +from .formationattack import FormationAttackBuilder, FormationAttackFlightPlan +from .waypointbuilder import WaypointBuilder + + +class EscortFlightPlan(FormationAttackFlightPlan): + @staticmethod + def builder_type() -> Type[Builder]: + return Builder + + +class Builder(FormationAttackBuilder[EscortFlightPlan]): + def build(self) -> FormationAttackFlightPlan: + assert self.package.waypoints is not None + + builder = WaypointBuilder(self.flight, self.coalition) + ingress, target = builder.escort( + self.package.waypoints.ingress, self.package.target + ) + hold = builder.hold(self._hold_point()) + join = builder.join(self.package.waypoints.join) + split = builder.split(self.package.waypoints.split) + refuel = None + if self.package.waypoints.refuel is not None: + refuel = builder.refuel(self.package.waypoints.refuel) + + return EscortFlightPlan( + flight=self.flight, + departure=builder.takeoff(self.flight.departure), + hold=hold, + hold_duration=timedelta(minutes=5), + nav_to=builder.nav_path( + hold.position, join.position, self.doctrine.ingress_altitude + ), + join=join, + ingress=ingress, + targets=[target], + split=split, + refuel=refuel, + nav_from=builder.nav_path( + split.position, + self.flight.arrival.position, + self.doctrine.ingress_altitude, + ), + arrival=builder.land(self.flight.arrival), + divert=builder.divert(self.flight.divert), + bullseye=builder.bullseye(), + ) diff --git a/game/ato/flightplans/ferry.py b/game/ato/flightplans/ferry.py new file mode 100644 index 00000000..08c98ce8 --- /dev/null +++ b/game/ato/flightplans/ferry.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +from collections.abc import Iterator +from datetime import timedelta +from typing import TYPE_CHECKING, Type + +from game.utils import feet +from .ibuilder import IBuilder +from .planningerror import PlanningError +from .standard import StandardFlightPlan +from .waypointbuilder import WaypointBuilder + +if TYPE_CHECKING: + from ..flight import Flight + from ..flightwaypoint import FlightWaypoint + + +class Builder(IBuilder): + def build(self) -> FerryFlightPlan: + if self.flight.departure == self.flight.arrival: + raise PlanningError( + f"Cannot plan ferry self.flight: departure and arrival are both " + f"{self.flight.departure}" + ) + + altitude_is_agl = self.flight.unit_type.dcs_unit_type.helicopter + altitude = ( + feet(1500) + if altitude_is_agl + else self.flight.unit_type.preferred_patrol_altitude + ) + + builder = WaypointBuilder(self.flight, self.coalition) + return FerryFlightPlan( + flight=self.flight, + departure=builder.takeoff(self.flight.departure), + nav_to_destination=builder.nav_path( + self.flight.departure.position, + self.flight.arrival.position, + altitude, + altitude_is_agl, + ), + arrival=builder.land(self.flight.arrival), + divert=builder.divert(self.flight.divert), + bullseye=builder.bullseye(), + ) + + +class FerryFlightPlan(StandardFlightPlan): + def __init__( + self, + flight: Flight, + departure: FlightWaypoint, + arrival: FlightWaypoint, + divert: FlightWaypoint | None, + bullseye: FlightWaypoint, + nav_to_destination: list[FlightWaypoint], + ) -> None: + super().__init__(flight, departure, arrival, divert, bullseye) + self.nav_to_destination = nav_to_destination + + @staticmethod + def builder_type() -> Type[Builder]: + return Builder + + def iter_waypoints(self) -> Iterator[FlightWaypoint]: + yield self.departure + yield from self.nav_to_destination + yield self.arrival + if self.divert is not None: + yield self.divert + yield self.bullseye + + @property + def tot_waypoint(self) -> FlightWaypoint | None: + return self.arrival + + def tot_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None: + # TOT planning isn't really useful for ferries. They're behind the front + # lines so no need to wait for escorts or for other missions to complete. + return None + + def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None: + return None + + @property + def mission_departure_time(self) -> timedelta: + return self.package.time_over_target diff --git a/game/ato/flightplans/flightplan.py b/game/ato/flightplans/flightplan.py new file mode 100644 index 00000000..5c7f33fe --- /dev/null +++ b/game/ato/flightplans/flightplan.py @@ -0,0 +1,308 @@ +"""Flight plan generation. + +Flights are first planned generically by either the player or by the +MissionPlanner. Those only plan basic information like the objective, aircraft +type, and the size of the flight. The FlightPlanBuilder is responsible for +generating the waypoints for the mission. +""" +from __future__ import annotations + +import math +from abc import ABC, abstractmethod +from collections.abc import Iterator +from datetime import timedelta +from functools import cached_property +from typing import TYPE_CHECKING, Type, TypeGuard + +from game.typeguard import self_type_guard +from game.utils import Distance, Speed, meters +from .ibuilder import IBuilder +from .planningerror import PlanningError +from ..flightwaypointtype import FlightWaypointType +from ..starttype import StartType +from ..traveltime import GroundSpeed, TravelTime + +if TYPE_CHECKING: + from game.dcs.aircrafttype import FuelConsumption + from game.theater import ControlPoint + from ..flight import Flight + from ..flightwaypoint import FlightWaypoint + from ..package import Package + from .formation import FormationFlightPlan + from .loiter import LoiterFlightPlan + from .patrolling import PatrollingFlightPlan + +INGRESS_TYPES = { + FlightWaypointType.INGRESS_CAS, + FlightWaypointType.INGRESS_ESCORT, + FlightWaypointType.INGRESS_SEAD, + FlightWaypointType.INGRESS_STRIKE, + FlightWaypointType.INGRESS_DEAD, +} + + +class FlightPlan(ABC): + def __init__(self, flight: Flight) -> None: + self.flight = flight + + @property + def package(self) -> Package: + return self.flight.package + + @staticmethod + @abstractmethod + def builder_type() -> Type[IBuilder]: + ... + + @property + def waypoints(self) -> list[FlightWaypoint]: + """A list of all waypoints in the flight plan, in order.""" + return list(self.iter_waypoints()) + + def iter_waypoints(self) -> Iterator[FlightWaypoint]: + """Iterates over all waypoints in the flight plan, in order.""" + raise NotImplementedError + + def edges( + self, until: FlightWaypoint | None = None + ) -> Iterator[tuple[FlightWaypoint, FlightWaypoint]]: + """A list of all paths between waypoints, in order.""" + waypoints = self.waypoints + if until is None: + last_index = len(waypoints) + else: + last_index = waypoints.index(until) + 1 + + return zip(self.waypoints[:last_index], self.waypoints[1:last_index]) + + def best_speed_between_waypoints( + self, a: FlightWaypoint, b: FlightWaypoint + ) -> Speed: + """Desired ground speed between points a and b.""" + factor = 1.0 + if b.waypoint_type == FlightWaypointType.ASCEND_POINT: + # Flights that start airborne already have some altitude and a good + # amount of speed. + factor = 0.5 + elif b.waypoint_type == FlightWaypointType.LOITER: + # On the way to the hold point the AI won't climb unless they're in + # formation, so slowing down the flight lead gives them more time to + # form up and climb. + # https://forums.eagle.ru/forum/english/digital-combat-simulator/dcs-world-2-5/dcs-wishlist-aa/7121300-ai-flights-will-not-climb-to-hold-point-because-wingman-not-joined + # + # Plus, it's a loiter point so there's no reason to hurry. + factor = 0.75 + # TODO: Adjust if AGL. + # We don't have an exact heightmap, but we should probably be performing + # *some* adjustment for NTTR since the minimum altitude of the map is + # near 2000 ft MSL. + return GroundSpeed.for_flight(self.flight, min(a.alt, b.alt)) * factor + + def speed_between_waypoints(self, a: FlightWaypoint, b: FlightWaypoint) -> Speed: + return self.best_speed_between_waypoints(a, b) + + @property + def combat_speed_waypoints(self) -> set[FlightWaypoint]: + return set() + + def fuel_consumption_between_points( + self, a: FlightWaypoint, b: FlightWaypoint + ) -> float | None: + ppm = self.fuel_rate_to_between_points(a, b) + if ppm is None: + return None + distance = meters(a.position.distance_to_point(b.position)) + return distance.nautical_miles * ppm + + def fuel_rate_to_between_points( + self, a: FlightWaypoint, b: FlightWaypoint + ) -> float | None: + if self.flight.unit_type.fuel_consumption is None: + return None + if a.waypoint_type is FlightWaypointType.TAKEOFF: + return self.flight.unit_type.fuel_consumption.climb + if b in self.combat_speed_waypoints: + return self.flight.unit_type.fuel_consumption.combat + return self.flight.unit_type.fuel_consumption.cruise + + @property + def tot_waypoint(self) -> FlightWaypoint | None: + """The waypoint that is associated with the package TOT, or None. + + Note that the only flight plans that should have no target waypoints are + user-planned missions without any useful waypoints and flight plans that + failed to generate. Nevertheless, we have to defend against it. + """ + raise NotImplementedError + + @property + def tot(self) -> timedelta: + return self.package.time_over_target + self.tot_offset + + @cached_property + def bingo_fuel(self) -> int: + """Bingo fuel value for the FlightPlan""" + if (fuel := self.flight.unit_type.fuel_consumption) is not None: + return self._bingo_estimate(fuel) + return self._legacy_bingo_estimate() + + def _bingo_estimate(self, fuel: FuelConsumption) -> int: + distance_to_arrival = self.max_distance_from(self.flight.arrival) + fuel_consumed = fuel.cruise * distance_to_arrival.nautical_miles + bingo = fuel_consumed + fuel.min_safe + return math.ceil(bingo / 100) * 100 + + def _legacy_bingo_estimate(self) -> int: + distance_to_arrival = self.max_distance_from(self.flight.arrival) + + bingo = 1000.0 # Minimum Emergency Fuel + bingo += 500 # Visual Traffic + bingo += 15 * distance_to_arrival.nautical_miles + + # TODO: Per aircraft tweaks. + + if self.flight.divert is not None: + max_divert_distance = self.max_distance_from(self.flight.divert) + bingo += 10 * max_divert_distance.nautical_miles + + return round(bingo / 100) * 100 + + @cached_property + def joker_fuel(self) -> int: + """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. + """ + if not self.waypoints: + return meters(0) + return max( + [meters(cp.position.distance_to_point(w.position)) for w in self.waypoints] + ) + + @property + def tot_offset(self) -> timedelta: + """This flight's offset from the package's TOT. + + Positive values represent later TOTs. An offset of -2 minutes is used + for a flight that has a TOT 2 minutes before the rest of the package. + """ + return timedelta() + + def _travel_time_to_waypoint(self, destination: FlightWaypoint) -> timedelta: + total = timedelta() + + if destination not in self.waypoints: + raise PlanningError( + f"Did not find destination waypoint {destination} in " + f"waypoints for {self.flight}" + ) + + for previous_waypoint, waypoint in self.edges(until=destination): + total += self.travel_time_between_waypoints(previous_waypoint, waypoint) + return total + + def travel_time_between_waypoints( + self, a: FlightWaypoint, b: FlightWaypoint + ) -> timedelta: + return TravelTime.between_points( + a.position, b.position, self.speed_between_waypoints(a, b) + ) + + def tot_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None: + raise NotImplementedError + + def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None: + raise NotImplementedError + + def request_escort_at(self) -> FlightWaypoint | None: + return None + + def dismiss_escort_at(self) -> FlightWaypoint | None: + return None + + def escorted_waypoints(self) -> Iterator[FlightWaypoint]: + begin = self.request_escort_at() + end = self.dismiss_escort_at() + if begin is None or end is None: + return + escorting = False + for waypoint in self.waypoints: + if waypoint == begin: + escorting = True + if escorting: + yield waypoint + if waypoint == end: + return + + def takeoff_time(self) -> timedelta | None: + tot_waypoint = self.tot_waypoint + if tot_waypoint is None: + return None + return self.tot - self._travel_time_to_waypoint(tot_waypoint) + + def startup_time(self) -> timedelta | None: + takeoff_time = self.takeoff_time() + if takeoff_time is None: + return None + + start_time: timedelta = ( + takeoff_time - self.estimate_startup() - self.estimate_ground_ops() + ) + + # In case FP math has given us some barely below zero time, round to + # zero. + if math.isclose(start_time.total_seconds(), 0): + start_time = timedelta() + + # Trim microseconds. DCS doesn't handle sub-second resolution for tasks, + # and they're not interesting from a mission planning perspective so we + # don't want them in the UI. + # + # Round down so *barely* above zero start times are just zero. + start_time = timedelta(seconds=math.floor(start_time.total_seconds())) + + # Feature request #1309: Carrier planes should start at +1s + # This is a workaround to a DCS problem: some AI planes spawn on + # the 'sixpack' when start_time is zero and cause a deadlock. + # Workaround: force the start_time to 1 second for these planes. + if self.flight.from_cp.is_fleet and start_time.total_seconds() == 0: + start_time = timedelta(seconds=1) + + return start_time + + def estimate_startup(self) -> timedelta: + if self.flight.start_type is StartType.COLD: + if self.flight.client_count: + return timedelta(minutes=10) + else: + # The AI doesn't seem to have a real startup procedure. + return timedelta(minutes=2) + return timedelta() + + def estimate_ground_ops(self) -> timedelta: + if self.flight.start_type in {StartType.RUNWAY, StartType.IN_FLIGHT}: + return timedelta() + if self.flight.from_cp.is_fleet: + return timedelta(minutes=2) + else: + return timedelta(minutes=8) + + @property + def mission_departure_time(self) -> timedelta: + """The time that the mission is complete and the flight RTBs.""" + raise NotImplementedError + + @self_type_guard + def is_loiter(self, flight_plan: FlightPlan) -> TypeGuard[LoiterFlightPlan]: + return False + + @self_type_guard + def is_patrol(self, flight_plan: FlightPlan) -> TypeGuard[PatrollingFlightPlan]: + return False + + @self_type_guard + def is_formation(self, flight_plan: FlightPlan) -> TypeGuard[FormationFlightPlan]: + return False diff --git a/game/ato/flightplans/flightplanbuilder.py b/game/ato/flightplans/flightplanbuilder.py new file mode 100644 index 00000000..3f4ca46e --- /dev/null +++ b/game/ato/flightplans/flightplanbuilder.py @@ -0,0 +1,193 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Type + +from game.ato import FlightType +from game.ato.closestairfields import ObjectiveDistanceCache +from game.data.doctrine import Doctrine +from game.flightplan import IpZoneGeometry, JoinZoneGeometry +from game.flightplan.refuelzonegeometry import RefuelZoneGeometry +from .aewc import AewcFlightPlan +from .airlift import AirliftFlightPlan +from .antiship import AntiShipFlightPlan +from .bai import BaiFlightPlan +from .barcap import BarCapFlightPlan +from .cas import CasFlightPlan +from .dead import DeadFlightPlan +from .escort import EscortFlightPlan +from .ferry import FerryFlightPlan +from .flightplan import FlightPlan +from .ocaaircraft import OcaAircraftFlightPlan +from .ocarunway import OcaRunwayFlightPlan +from .packagerefueling import PackageRefuelingFlightPlan +from .planningerror import PlanningError +from .sead import SeadFlightPlan +from .strike import StrikeFlightPlan +from .sweep import SweepFlightPlan +from .tarcap import TarCapFlightPlan +from .theaterrefueling import TheaterRefuelingFlightPlan +from .waypointbuilder import WaypointBuilder + +if TYPE_CHECKING: + from game.ato import Flight, FlightWaypoint, Package + from game.coalition import Coalition + from game.theater import ConflictTheater, ControlPoint, FrontLine + from game.threatzones import ThreatZones + + +class FlightPlanBuilder: + """Generates flight plans for flights.""" + + def __init__( + self, package: Package, coalition: Coalition, theater: ConflictTheater + ) -> None: + # TODO: Plan similar altitudes for the in-country leg of the mission. + # Waypoint altitudes for a given flight *shouldn't* differ too much + # between the join and split points, so we don't need speeds for each + # leg individually since they should all be fairly similar. This doesn't + # hold too well right now since nothing is stopping each waypoint from + # jumping 20k feet each time, but that's a huge waste of energy we + # should be avoiding anyway. + self.package = package + self.coalition = coalition + self.theater = theater + + @property + def is_player(self) -> bool: + return self.coalition.player + + @property + def doctrine(self) -> Doctrine: + return self.coalition.doctrine + + @property + def threat_zones(self) -> ThreatZones: + return self.coalition.opponent.threat_zone + + def populate_flight_plan(self, flight: Flight) -> None: + """Creates a default flight plan for the given mission.""" + if flight not in self.package.flights: + raise RuntimeError("Flight must be a part of the package") + + from game.navmesh import NavMeshError + + try: + if self.package.waypoints is None: + self.regenerate_package_waypoints() + flight.flight_plan = self.generate_flight_plan(flight) + except NavMeshError as ex: + color = "blue" if self.is_player else "red" + raise PlanningError( + f"Could not plan {color} {flight.flight_type.value} from " + f"{flight.departure} to {flight.package.target}" + ) from ex + + def plan_type(self, task: FlightType) -> Type[FlightPlan] | None: + plan_type: Type[FlightPlan] + if task == FlightType.REFUELING: + if self.package.target.is_friendly(self.is_player) or isinstance( + self.package.target, FrontLine + ): + return TheaterRefuelingFlightPlan + return PackageRefuelingFlightPlan + + plan_dict: dict[FlightType, Type[FlightPlan]] = { + FlightType.ANTISHIP: AntiShipFlightPlan, + FlightType.BAI: BaiFlightPlan, + FlightType.BARCAP: BarCapFlightPlan, + FlightType.CAS: CasFlightPlan, + FlightType.DEAD: DeadFlightPlan, + FlightType.ESCORT: EscortFlightPlan, + FlightType.OCA_AIRCRAFT: OcaAircraftFlightPlan, + FlightType.OCA_RUNWAY: OcaRunwayFlightPlan, + FlightType.SEAD: SeadFlightPlan, + FlightType.SEAD_ESCORT: EscortFlightPlan, + FlightType.STRIKE: StrikeFlightPlan, + FlightType.SWEEP: SweepFlightPlan, + FlightType.TARCAP: TarCapFlightPlan, + FlightType.AEWC: AewcFlightPlan, + FlightType.TRANSPORT: AirliftFlightPlan, + FlightType.FERRY: FerryFlightPlan, + } + return plan_dict.get(task) + + def generate_flight_plan(self, flight: Flight) -> FlightPlan: + plan_type = self.plan_type(flight.flight_type) + if plan_type is None: + raise PlanningError( + f"{flight.flight_type} flight plan generation not implemented" + ) + return plan_type.builder_type()(flight, self.theater).build() + + def regenerate_flight_plans(self) -> None: + new_flights: list[Flight] = [] + for old_flight in self.package.flights: + old_flight.flight_plan = self.generate_flight_plan(old_flight) + new_flights.append(old_flight) + self.package.flights = new_flights + + def regenerate_package_waypoints(self) -> None: + from game.ato.packagewaypoints import PackageWaypoints + + package_airfield = self.package_airfield() + + # Start by picking the best IP for the attack. + ingress_point = IpZoneGeometry( + self.package.target.position, + package_airfield.position, + self.coalition, + ).find_best_ip() + + join_point = JoinZoneGeometry( + self.package.target.position, + package_airfield.position, + ingress_point, + self.coalition, + ).find_best_join_point() + + refuel_point = RefuelZoneGeometry( + package_airfield.position, + join_point, + self.coalition, + ).find_best_refuel_point() + + # And the split point based on the best route from the IP. Since that's no + # different than the best route *to* the IP, this is the same as the join point. + # TODO: Estimate attack completion point based on the IP and split from there? + self.package.waypoints = PackageWaypoints( + WaypointBuilder.perturb(join_point), + ingress_point, + WaypointBuilder.perturb(join_point), + refuel_point, + ) + + # TODO: Make a model for the waypoint builder and use that in the UI. + def generate_rtb_waypoint( + self, flight: Flight, arrival: ControlPoint + ) -> FlightWaypoint: + """Generate RTB landing point. + + Args: + flight: The flight to generate the landing waypoint for. + arrival: Arrival airfield or carrier. + """ + builder = WaypointBuilder(flight, self.coalition) + return builder.land(arrival) + + def package_airfield(self) -> ControlPoint: + # We'll always have a package, but if this is being planned via the UI + # it could be the first flight in the package. + if not self.package.flights: + raise PlanningError( + "Cannot determine source airfield for package with no flights" + ) + + # The package airfield is either the flight's airfield (when there is no + # package) or the closest airfield to the objective that is the + # departure airfield for some flight in the package. + cache = ObjectiveDistanceCache.get_closest_airfields(self.package.target) + for airfield in cache.operational_airfields: + for flight in self.package.flights: + if flight.departure == airfield: + return airfield + raise PlanningError("Could not find any airfield assigned to this package") diff --git a/game/ato/flightplans/formation.py b/game/ato/flightplans/formation.py new file mode 100644 index 00000000..bb5df82e --- /dev/null +++ b/game/ato/flightplans/formation.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from datetime import timedelta +from functools import cached_property +from typing import TYPE_CHECKING, TypeGuard + +from game.typeguard import self_type_guard +from game.utils import Speed +from .flightplan import FlightPlan +from .loiter import LoiterFlightPlan +from ..traveltime import GroundSpeed, TravelTime + +if TYPE_CHECKING: + from ..flight import Flight + from ..flightwaypoint import FlightWaypoint + + +class FormationFlightPlan(LoiterFlightPlan, ABC): + def __init__( + self, + flight: Flight, + departure: FlightWaypoint, + arrival: FlightWaypoint, + divert: FlightWaypoint | None, + bullseye: FlightWaypoint, + nav_to: list[FlightWaypoint], + nav_from: list[FlightWaypoint], + hold: FlightWaypoint, + hold_duration: timedelta, + join: FlightWaypoint, + split: FlightWaypoint, + refuel: FlightWaypoint, + ) -> None: + super().__init__( + flight, + departure, + arrival, + divert, + bullseye, + nav_to, + nav_from, + hold, + hold_duration, + ) + self.join = join + self.split = split + self.refuel = refuel + + @property + @abstractmethod + def package_speed_waypoints(self) -> set[FlightWaypoint]: + ... + + @property + def combat_speed_waypoints(self) -> set[FlightWaypoint]: + return self.package_speed_waypoints + + def request_escort_at(self) -> FlightWaypoint | None: + return self.join + + def dismiss_escort_at(self) -> FlightWaypoint | None: + return self.split + + @cached_property + def best_flight_formation_speed(self) -> Speed: + """The best speed this flight is capable at all formation waypoints. + + To ease coordination with other flights, we aim to have a single mission + speed used by the formation for all waypoints. As such, this function + returns the highest ground speed that the flight is capable of flying at + all of its formation waypoints. + """ + speeds = [] + for previous_waypoint, waypoint in self.edges(): + if waypoint in self.package_speed_waypoints: + speeds.append( + self.best_speed_between_waypoints(previous_waypoint, waypoint) + ) + return min(speeds) + + def speed_between_waypoints(self, a: FlightWaypoint, b: FlightWaypoint) -> Speed: + if b in self.package_speed_waypoints: + # Should be impossible, as any package with at least one + # FormationFlightPlan flight needs a formation speed. + assert self.package.formation_speed is not None + return self.package.formation_speed + return super().speed_between_waypoints(a, b) + + @property + def travel_time_to_rendezvous(self) -> timedelta: + """The estimated time between the first waypoint and the join point.""" + return self._travel_time_to_waypoint(self.join) + + @property + @abstractmethod + def join_time(self) -> timedelta: + ... + + @property + @abstractmethod + def split_time(self) -> timedelta: + ... + + def tot_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None: + if waypoint == self.join: + return self.join_time + elif waypoint == self.split: + return self.split_time + return None + + @property + def push_time(self) -> timedelta: + return self.join_time - TravelTime.between_points( + self.hold.position, + self.join.position, + GroundSpeed.for_flight(self.flight, self.hold.alt), + ) + + @property + def mission_departure_time(self) -> timedelta: + return self.split_time + + @self_type_guard + def is_formation(self, flight_plan: FlightPlan) -> TypeGuard[FormationFlightPlan]: + return True diff --git a/game/ato/flightplans/formationattack.py b/game/ato/flightplans/formationattack.py new file mode 100644 index 00000000..6be043f2 --- /dev/null +++ b/game/ato/flightplans/formationattack.py @@ -0,0 +1,271 @@ +from __future__ import annotations + +from abc import ABC +from collections.abc import Iterator +from datetime import timedelta +from typing import Generic, TYPE_CHECKING, Type, TypeVar + +from dcs import Point + +from game.flightplan import HoldZoneGeometry +from game.theater import MissionTarget +from game.utils import Speed, meters +from .formation import FormationFlightPlan +from .ibuilder import IBuilder +from .planningerror import PlanningError +from .waypointbuilder import StrikeTarget, WaypointBuilder +from .. import FlightType +from ..flightwaypoint import FlightWaypoint +from ..flightwaypointtype import FlightWaypointType + +if TYPE_CHECKING: + from ..flight import Flight + + +class FormationAttackFlightPlan(FormationFlightPlan, ABC): + def __init__( + self, + flight: Flight, + departure: FlightWaypoint, + arrival: FlightWaypoint, + divert: FlightWaypoint | None, + bullseye: FlightWaypoint, + nav_to: list[FlightWaypoint], + nav_from: list[FlightWaypoint], + hold: FlightWaypoint, + hold_duration: timedelta, + join: FlightWaypoint, + split: FlightWaypoint, + refuel: FlightWaypoint, + ingress: FlightWaypoint, + targets: list[FlightWaypoint], + lead_time: timedelta = timedelta(), + ) -> None: + super().__init__( + flight, + departure, + arrival, + divert, + bullseye, + nav_to, + nav_from, + hold, + hold_duration, + join, + split, + refuel, + ) + self.ingress = ingress + self.targets = targets + self.lead_time = lead_time + + def iter_waypoints(self) -> Iterator[FlightWaypoint]: + yield self.departure + yield self.hold + yield from self.nav_to + yield self.join + yield self.ingress + yield from self.targets + yield self.split + if self.refuel is not None: + yield self.refuel + yield from self.nav_from + yield self.arrival + if self.divert is not None: + yield self.divert + yield self.bullseye + + @property + def package_speed_waypoints(self) -> set[FlightWaypoint]: + return { + self.ingress, + self.split, + } | set(self.targets) + + def speed_between_waypoints(self, a: FlightWaypoint, b: FlightWaypoint) -> Speed: + # FlightWaypoint is only comparable by identity, so adding + # target_area_waypoint to package_speed_waypoints is useless. + if b.waypoint_type == FlightWaypointType.TARGET_GROUP_LOC: + # Should be impossible, as any package with at least one + # FormationFlightPlan flight needs a formation speed. + assert self.package.formation_speed is not None + return self.package.formation_speed + return super().speed_between_waypoints(a, b) + + @property + def tot_waypoint(self) -> FlightWaypoint: + return self.targets[0] + + @property + def tot_offset(self) -> timedelta: + try: + return -self.lead_time + except AttributeError: + return timedelta() + + @property + def target_area_waypoint(self) -> FlightWaypoint: + return FlightWaypoint( + "TARGET AREA", + FlightWaypointType.TARGET_GROUP_LOC, + self.package.target.position, + meters(0), + "RADIO", + ) + + @property + def travel_time_to_target(self) -> timedelta: + """The estimated time between the first waypoint and the target.""" + destination = self.tot_waypoint + total = timedelta() + for previous_waypoint, waypoint in self.edges(): + if waypoint == self.tot_waypoint: + # For anything strike-like the TOT waypoint is the *flight's* + # mission target, but to synchronize with the rest of the + # package we need to use the travel time to the same position as + # the others. + total += self.travel_time_between_waypoints( + previous_waypoint, self.target_area_waypoint + ) + break + total += self.travel_time_between_waypoints(previous_waypoint, waypoint) + else: + raise PlanningError( + f"Did not find destination waypoint {destination} in " + f"waypoints for {self.flight}" + ) + return total + + @property + def join_time(self) -> timedelta: + travel_time = self.travel_time_between_waypoints(self.join, self.ingress) + return self.ingress_time - travel_time + + @property + def split_time(self) -> timedelta: + travel_time_ingress = self.travel_time_between_waypoints( + self.ingress, self.target_area_waypoint + ) + travel_time_egress = self.travel_time_between_waypoints( + self.target_area_waypoint, self.split + ) + minutes_at_target = 0.75 * len(self.targets) + timedelta_at_target = timedelta(minutes=minutes_at_target) + return ( + self.ingress_time + + travel_time_ingress + + timedelta_at_target + + travel_time_egress + ) + + @property + def ingress_time(self) -> timedelta: + tot = self.tot + travel_time = self.travel_time_between_waypoints( + self.ingress, self.target_area_waypoint + ) + return tot - travel_time + + def tot_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None: + if waypoint == self.ingress: + return self.ingress_time + elif waypoint in self.targets: + return self.tot + return super().tot_for_waypoint(waypoint) + + +FlightPlanT = TypeVar("FlightPlanT", bound=FormationAttackFlightPlan) + + +class FormationAttackBuilder(IBuilder, ABC, Generic[FlightPlanT]): + def _build( + self, + plan_type: Type[FlightPlanT], + ingress_type: FlightWaypointType, + targets: list[StrikeTarget] | None = None, + lead_time: timedelta = timedelta(), + ) -> FlightPlanT: + assert self.package.waypoints is not None + builder = WaypointBuilder(self.flight, self.coalition, targets) + + target_waypoints: list[FlightWaypoint] = [] + if targets is not None: + for target in targets: + target_waypoints.append( + self.target_waypoint(self.flight, builder, target) + ) + else: + target_waypoints.append( + self.target_area_waypoint( + self.flight, self.flight.package.target, builder + ) + ) + + hold = builder.hold(self._hold_point()) + join = builder.join(self.package.waypoints.join) + split = builder.split(self.package.waypoints.split) + refuel = None + if self.package.waypoints.refuel is not None: + refuel = builder.refuel(self.package.waypoints.refuel) + + return plan_type( + flight=self.flight, + departure=builder.takeoff(self.flight.departure), + hold=hold, + hold_duration=timedelta(minutes=5), + nav_to=builder.nav_path( + hold.position, join.position, self.doctrine.ingress_altitude + ), + join=join, + ingress=builder.ingress( + ingress_type, self.package.waypoints.ingress, self.package.target + ), + targets=target_waypoints, + split=split, + refuel=refuel, + nav_from=builder.nav_path( + split.position, + self.flight.arrival.position, + self.doctrine.ingress_altitude, + ), + arrival=builder.land(self.flight.arrival), + divert=builder.divert(self.flight.divert), + bullseye=builder.bullseye(), + lead_time=lead_time, + ) + + @staticmethod + def target_waypoint( + flight: Flight, builder: WaypointBuilder, target: StrikeTarget + ) -> FlightWaypoint: + if flight.flight_type in {FlightType.ANTISHIP, FlightType.BAI}: + return builder.bai_group(target) + elif flight.flight_type == FlightType.DEAD: + return builder.dead_point(target) + elif flight.flight_type == FlightType.SEAD: + return builder.sead_point(target) + else: + return builder.strike_point(target) + + @staticmethod + def target_area_waypoint( + flight: Flight, location: MissionTarget, builder: WaypointBuilder + ) -> FlightWaypoint: + if flight.flight_type == FlightType.DEAD: + return builder.dead_area(location) + elif flight.flight_type == FlightType.SEAD: + return builder.sead_area(location) + elif flight.flight_type == FlightType.OCA_AIRCRAFT: + return builder.oca_strike_area(location) + else: + return builder.strike_area(location) + + def _hold_point(self) -> Point: + assert self.package.waypoints is not None + origin = self.flight.departure.position + target = self.package.target.position + join = self.package.waypoints.join + ip = self.package.waypoints.ingress + return HoldZoneGeometry( + target, origin, ip, join, self.coalition, self.theater + ).find_best_hold_point() diff --git a/game/ato/flightplans/ibuilder.py b/game/ato/flightplans/ibuilder.py new file mode 100644 index 00000000..c0e9ff28 --- /dev/null +++ b/game/ato/flightplans/ibuilder.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from game.coalition import Coalition + from game.data.doctrine import Doctrine + from game.theater import ConflictTheater + from game.threatzones import ThreatZones + from ..flight import Flight + from ..package import Package + from .flightplan import FlightPlan + + +class IBuilder(ABC): + def __init__(self, flight: Flight, theater: ConflictTheater) -> None: + self.flight = flight + self.theater = theater + + @abstractmethod + def build(self) -> FlightPlan: + ... + + @property + def package(self) -> Package: + return self.flight.package + + @property + def coalition(self) -> Coalition: + return self.flight.coalition + + @property + def is_player(self) -> bool: + return self.coalition.player + + @property + def doctrine(self) -> Doctrine: + return self.coalition.doctrine + + @property + def threat_zones(self) -> ThreatZones: + return self.coalition.opponent.threat_zone diff --git a/game/ato/flightplans/invalidobjectivelocation.py b/game/ato/flightplans/invalidobjectivelocation.py new file mode 100644 index 00000000..fe4eedbb --- /dev/null +++ b/game/ato/flightplans/invalidobjectivelocation.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from game.ato import FlightType +from game.ato.flightplans.planningerror import PlanningError +from game.theater import MissionTarget + + +class InvalidObjectiveLocation(PlanningError): + """Raised when the objective location is invalid for the mission type.""" + + def __init__(self, task: FlightType, location: MissionTarget) -> None: + super().__init__(f"{location.name} is not valid for {task} missions.") diff --git a/game/ato/flightplans/loiter.py b/game/ato/flightplans/loiter.py new file mode 100644 index 00000000..3a30f968 --- /dev/null +++ b/game/ato/flightplans/loiter.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from datetime import timedelta +from typing import TYPE_CHECKING, TypeGuard + +from game.typeguard import self_type_guard +from .flightplan import FlightPlan +from .standard import StandardFlightPlan + +if TYPE_CHECKING: + from ..flight import Flight + from ..flightwaypoint import FlightWaypoint + + +class LoiterFlightPlan(StandardFlightPlan, ABC): + def __init__( + self, + flight: Flight, + departure: FlightWaypoint, + arrival: FlightWaypoint, + divert: FlightWaypoint | None, + bullseye: FlightWaypoint, + nav_to: list[FlightWaypoint], + nav_from: list[FlightWaypoint], + hold: FlightWaypoint, + hold_duration: timedelta, + ) -> None: + super().__init__(flight, departure, arrival, divert, bullseye) + self.nav_to = nav_to + self.nav_from = nav_from + self.hold = hold + self.hold_duration = hold_duration + + @property + @abstractmethod + def push_time(self) -> timedelta: + ... + + def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None: + if waypoint == self.hold: + return self.push_time + return None + + def travel_time_between_waypoints( + self, a: FlightWaypoint, b: FlightWaypoint + ) -> timedelta: + travel_time = super().travel_time_between_waypoints(a, b) + if a != self.hold: + return travel_time + return travel_time + self.hold_duration + + @self_type_guard + def is_loiter(self, flight_plan: FlightPlan) -> TypeGuard[LoiterFlightPlan]: + return True diff --git a/game/ato/flightplans/ocaaircraft.py b/game/ato/flightplans/ocaaircraft.py new file mode 100644 index 00000000..b831a5e0 --- /dev/null +++ b/game/ato/flightplans/ocaaircraft.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import logging +from typing import Type + +from game.theater import Airfield +from .formationattack import FormationAttackBuilder, FormationAttackFlightPlan +from .invalidobjectivelocation import InvalidObjectiveLocation +from ..flightwaypointtype import FlightWaypointType + + +class OcaAircraftFlightPlan(FormationAttackFlightPlan): + @staticmethod + def builder_type() -> Type[Builder]: + return Builder + + +class Builder(FormationAttackBuilder[OcaAircraftFlightPlan]): + def build(self) -> FormationAttackFlightPlan: + location = self.package.target + + if not isinstance(location, Airfield): + logging.exception( + f"Invalid Objective Location for OCA/Aircraft flight " + f"{self.flight=} at {location=}." + ) + raise InvalidObjectiveLocation(self.flight.flight_type, location) + + return self._build( + OcaAircraftFlightPlan, FlightWaypointType.INGRESS_OCA_AIRCRAFT + ) diff --git a/game/ato/flightplans/ocarunway.py b/game/ato/flightplans/ocarunway.py new file mode 100644 index 00000000..feabbc17 --- /dev/null +++ b/game/ato/flightplans/ocarunway.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +import logging +from typing import Type + +from game.theater import Airfield +from .formationattack import FormationAttackBuilder, FormationAttackFlightPlan +from .invalidobjectivelocation import InvalidObjectiveLocation +from ..flightwaypointtype import FlightWaypointType + + +class OcaRunwayFlightPlan(FormationAttackFlightPlan): + @staticmethod + def builder_type() -> Type[Builder]: + return Builder + + +class Builder(FormationAttackBuilder[OcaRunwayFlightPlan]): + def build(self) -> FormationAttackFlightPlan: + location = self.package.target + + if not isinstance(location, Airfield): + logging.exception( + f"Invalid Objective Location for OCA/Runway flight " + f"{self.flight=} at {location=}." + ) + raise InvalidObjectiveLocation(self.flight.flight_type, location) + + return self._build(OcaRunwayFlightPlan, FlightWaypointType.INGRESS_OCA_RUNWAY) diff --git a/game/ato/flightplans/packagerefueling.py b/game/ato/flightplans/packagerefueling.py new file mode 100644 index 00000000..5683bd32 --- /dev/null +++ b/game/ato/flightplans/packagerefueling.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +from datetime import timedelta +from typing import Type + +from dcs import Point + +from game.utils import Distance, Heading, feet, knots, meters +from .theaterrefueling import ( + Builder as TheaterRefuelingBuilder, + TheaterRefuelingFlightPlan, +) +from .waypointbuilder import WaypointBuilder +from ..flightwaypoint import FlightWaypoint +from ..flightwaypointtype import FlightWaypointType + + +class Builder(TheaterRefuelingBuilder): + def build(self) -> PackageRefuelingFlightPlan: + package_waypoints = self.package.waypoints + assert package_waypoints is not None + + racetrack_half_distance = Distance.from_nautical_miles(20).meters + # TODO: Only consider aircraft that can refuel with this tanker type. + refuel_time_minutes = 5 + for self.flight in self.package.flights: + flight_size = self.flight.roster.max_size + refuel_time_minutes = refuel_time_minutes + 4 * flight_size + 1 + + patrol_duration = timedelta(minutes=refuel_time_minutes) + + racetrack_center = package_waypoints.refuel + + split_heading = Heading.from_degrees( + racetrack_center.heading_between_point(package_waypoints.split) + ) + home_heading = split_heading.opposite + + racetrack_start = racetrack_center.point_from_heading( + split_heading.degrees, racetrack_half_distance + ) + + racetrack_end = racetrack_center.point_from_heading( + home_heading.degrees, racetrack_half_distance + ) + + builder = WaypointBuilder(self.flight, self.coalition) + + tanker_type = self.flight.unit_type + if tanker_type.patrol_altitude is not None: + altitude = tanker_type.patrol_altitude + else: + altitude = feet(21000) + + # TODO: Could use self.flight.unit_type.preferred_patrol_speed(altitude). + if tanker_type.patrol_speed is not None: + speed = tanker_type.patrol_speed + else: + # ~280 knots IAS at 21000. + speed = knots(400) + + racetrack = builder.race_track(racetrack_start, racetrack_end, altitude) + + return PackageRefuelingFlightPlan( + flight=self.flight, + departure=builder.takeoff(self.flight.departure), + nav_to=builder.nav_path( + self.flight.departure.position, racetrack_start, altitude + ), + nav_from=builder.nav_path( + racetrack_end, self.flight.arrival.position, altitude + ), + patrol_start=racetrack[0], + patrol_end=racetrack[1], + arrival=builder.land(self.flight.arrival), + divert=builder.divert(self.flight.divert), + bullseye=builder.bullseye(), + patrol_duration=patrol_duration, + patrol_speed=speed, + # TODO: Factor out a common base of the combat and non-combat race-tracks. + # No harm in setting this, but we ought to clean up a bit. + engagement_distance=meters(0), + ) + + +class PackageRefuelingFlightPlan(TheaterRefuelingFlightPlan): + @staticmethod + def builder_type() -> Type[Builder]: + return Builder + + def target_area_waypoint(self) -> FlightWaypoint: + return FlightWaypoint( + "TARGET AREA", + FlightWaypointType.TARGET_GROUP_LOC, + self.package.target.position, + meters(0), + "RADIO", + ) + + @property + def patrol_start_time(self) -> timedelta: + altitude = self.flight.unit_type.patrol_altitude + + if altitude is None: + altitude = Distance.from_feet(20000) + + assert self.package.waypoints is not None + + # Cheat in a FlightWaypoint for the split point. + split: Point = self.package.waypoints.split + split_waypoint: FlightWaypoint = FlightWaypoint( + "SPLIT", FlightWaypointType.SPLIT, split, altitude + ) + + # Cheat in a FlightWaypoint for the refuel point. + refuel: Point = self.package.waypoints.refuel + refuel_waypoint: FlightWaypoint = FlightWaypoint( + "REFUEL", FlightWaypointType.REFUEL, refuel, altitude + ) + + delay_target_to_split: timedelta = self.travel_time_between_waypoints( + self.target_area_waypoint(), split_waypoint + ) + delay_split_to_refuel: timedelta = self.travel_time_between_waypoints( + split_waypoint, refuel_waypoint + ) + + return ( + self.package.time_over_target + + delay_target_to_split + + delay_split_to_refuel + - timedelta(minutes=1.5) + ) diff --git a/game/ato/flightplans/patrolling.py b/game/ato/flightplans/patrolling.py new file mode 100644 index 00000000..f8928834 --- /dev/null +++ b/game/ato/flightplans/patrolling.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +from abc import ABC +from collections.abc import Iterator +from datetime import timedelta +from typing import TYPE_CHECKING, TypeGuard + +from game.ato.flightplans.standard import StandardFlightPlan +from game.typeguard import self_type_guard +from game.utils import Distance, Speed + +if TYPE_CHECKING: + from ..flight import Flight + from ..flightwaypoint import FlightWaypoint + from .flightplan import FlightPlan + + +class PatrollingFlightPlan(StandardFlightPlan, ABC): + def __init__( + self, + flight: Flight, + departure: FlightWaypoint, + arrival: FlightWaypoint, + divert: FlightWaypoint | None, + bullseye: FlightWaypoint, + nav_to: list[FlightWaypoint], + nav_from: list[FlightWaypoint], + patrol_start: FlightWaypoint, + patrol_end: FlightWaypoint, + patrol_duration: timedelta, + patrol_speed: Speed, + engagement_distance: Distance, + ) -> None: + super().__init__(flight, departure, arrival, divert, bullseye) + self.nav_to = nav_to + self.nav_from = nav_from + self.patrol_start = patrol_start + self.patrol_end = patrol_end + + # Maximum time to remain on station. + self.patrol_duration = patrol_duration + + # Racetrack speed TAS. + self.patrol_speed = patrol_speed + + # The engagement range of any Search Then Engage task, or the radius of a + # Search Then Engage in Zone task. Any enemies of the appropriate type for + # this mission within this range of the flight's current position (or the + # center of the zone) will be engaged by the flight. + self.engagement_distance = engagement_distance + + @property + def patrol_start_time(self) -> timedelta: + return self.package.time_over_target + + @property + def patrol_end_time(self) -> timedelta: + # TODO: This is currently wrong for CAS. + # CAS missions end when they're winchester or bingo. We need to + # configure push tasks for the escorts rather than relying on timing. + return self.patrol_start_time + self.patrol_duration + + def tot_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None: + if waypoint == self.patrol_start: + return self.patrol_start_time + return None + + def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None: + if waypoint == self.patrol_end: + return self.patrol_end_time + return None + + def iter_waypoints(self) -> Iterator[FlightWaypoint]: + yield self.departure + yield from self.nav_to + yield self.patrol_start + yield self.patrol_end + yield from self.nav_from + yield self.arrival + if self.divert is not None: + yield self.divert + yield self.bullseye + + @property + def package_speed_waypoints(self) -> set[FlightWaypoint]: + return {self.patrol_start, self.patrol_end} + + @property + def tot_waypoint(self) -> FlightWaypoint | None: + return self.patrol_start + + @property + def mission_departure_time(self) -> timedelta: + return self.patrol_end_time + + @self_type_guard + def is_patrol(self, flight_plan: FlightPlan) -> TypeGuard[PatrollingFlightPlan]: + return True diff --git a/game/ato/flightplans/planningerror.py b/game/ato/flightplans/planningerror.py new file mode 100644 index 00000000..d11c1baf --- /dev/null +++ b/game/ato/flightplans/planningerror.py @@ -0,0 +1,5 @@ +from __future__ import annotations + + +class PlanningError(RuntimeError): + """Raised when the flight planner was unable to create a flight plan.""" diff --git a/game/ato/flightplans/rtb.py b/game/ato/flightplans/rtb.py new file mode 100644 index 00000000..8f84a9bc --- /dev/null +++ b/game/ato/flightplans/rtb.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +from collections.abc import Iterator +from datetime import timedelta +from typing import TYPE_CHECKING, Type + +from game.utils import feet +from .ibuilder import IBuilder +from .standard import StandardFlightPlan +from .waypointbuilder import WaypointBuilder +from ..flightstate import InFlight + +if TYPE_CHECKING: + from ..flight import Flight + from ..flightwaypoint import FlightWaypoint + + +class Builder(IBuilder): + def build(self) -> RtbFlightPlan: + if not isinstance(self.flight.state, InFlight): + raise RuntimeError(f"Cannot abort {self} because it is not in flight") + + current_position = self.flight.state.estimate_position() + current_altitude, altitude_reference = self.flight.state.estimate_altitude() + + altitude_is_agl = self.flight.unit_type.dcs_unit_type.helicopter + altitude = ( + feet(1500) + if altitude_is_agl + else self.flight.unit_type.preferred_patrol_altitude + ) + builder = WaypointBuilder(self.flight, self.flight.coalition) + abort_point = builder.nav( + current_position, current_altitude, altitude_reference == "RADIO" + ) + abort_point.name = "ABORT AND RTB" + abort_point.pretty_name = "Abort and RTB" + abort_point.description = "Abort mission and return to base" + return RtbFlightPlan( + flight=self.flight, + departure=builder.takeoff(self.flight.departure), + abort_location=abort_point, + nav_to_destination=builder.nav_path( + current_position, + self.flight.arrival.position, + altitude, + altitude_is_agl, + ), + arrival=builder.land(self.flight.arrival), + divert=builder.divert(self.flight.divert), + bullseye=builder.bullseye(), + ) + + +class RtbFlightPlan(StandardFlightPlan): + def __init__( + self, + flight: Flight, + departure: FlightWaypoint, + arrival: FlightWaypoint, + divert: FlightWaypoint | None, + bullseye: FlightWaypoint, + abort_location: FlightWaypoint, + nav_to_destination: list[FlightWaypoint], + ) -> None: + super().__init__(flight, departure, arrival, divert, bullseye) + self.abort_location = abort_location + self.nav_to_destination = nav_to_destination + + @staticmethod + def builder_type() -> Type[Builder]: + return Builder + + def iter_waypoints(self) -> Iterator[FlightWaypoint]: + yield self.departure + yield self.abort_location + yield from self.nav_to_destination + yield self.arrival + if self.divert is not None: + yield self.divert + yield self.bullseye + + @property + def abort_index(self) -> int: + return 1 + + @property + def tot_waypoint(self) -> FlightWaypoint | None: + return None + + def tot_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None: + return None + + def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None: + return None + + @property + def mission_departure_time(self) -> timedelta: + return timedelta() diff --git a/game/ato/flightplans/sead.py b/game/ato/flightplans/sead.py new file mode 100644 index 00000000..5b3157f9 --- /dev/null +++ b/game/ato/flightplans/sead.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from datetime import timedelta +from typing import Type + +from .formationattack import FormationAttackBuilder, FormationAttackFlightPlan +from ..flightwaypointtype import FlightWaypointType + + +class SeadFlightPlan(FormationAttackFlightPlan): + @staticmethod + def builder_type() -> Type[Builder]: + return Builder + + +class Builder(FormationAttackBuilder[SeadFlightPlan]): + def build(self) -> FormationAttackFlightPlan: + return self._build( + SeadFlightPlan, + FlightWaypointType.INGRESS_SEAD, + lead_time=timedelta(minutes=1), + ) diff --git a/game/ato/flightplans/standard.py b/game/ato/flightplans/standard.py new file mode 100644 index 00000000..7277271f --- /dev/null +++ b/game/ato/flightplans/standard.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from abc import ABC +from typing import TYPE_CHECKING + +from game.ato.flightplans.flightplan import FlightPlan + +if TYPE_CHECKING: + from ..flight import Flight + from ..flightwaypoint import FlightWaypoint + + +class StandardFlightPlan(FlightPlan, ABC): + """Base type for all non-custom flight plans. + + We can't reason about custom flight plans so they get special treatment, but all + others are guaranteed to have certain properties like departure and arrival points, + potentially a divert field, and a bullseye + """ + + def __init__( + self, + flight: Flight, + departure: FlightWaypoint, + arrival: FlightWaypoint, + divert: FlightWaypoint | None, + bullseye: FlightWaypoint, + ) -> None: + super().__init__(flight) + self.departure = departure + self.arrival = arrival + self.divert = divert + self.bullseye = bullseye diff --git a/game/ato/flightplans/strike.py b/game/ato/flightplans/strike.py new file mode 100644 index 00000000..04d608ac --- /dev/null +++ b/game/ato/flightplans/strike.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from typing import Type + +from game.theater import TheaterGroundObject +from .formationattack import FormationAttackBuilder, FormationAttackFlightPlan +from .invalidobjectivelocation import InvalidObjectiveLocation +from .waypointbuilder import StrikeTarget +from ..flightwaypointtype import FlightWaypointType + + +class StrikeFlightPlan(FormationAttackFlightPlan): + @staticmethod + def builder_type() -> Type[Builder]: + return Builder + + +class Builder(FormationAttackBuilder[StrikeFlightPlan]): + def build(self) -> FormationAttackFlightPlan: + location = self.package.target + + if not isinstance(location, TheaterGroundObject): + raise InvalidObjectiveLocation(self.flight.flight_type, location) + + targets: list[StrikeTarget] = [] + for idx, unit in enumerate(location.strike_targets): + targets.append(StrikeTarget(f"{unit.type.id} #{idx}", unit)) + + return self._build(StrikeFlightPlan, FlightWaypointType.INGRESS_STRIKE, targets) diff --git a/game/ato/flightplans/sweep.py b/game/ato/flightplans/sweep.py new file mode 100644 index 00000000..e502d94a --- /dev/null +++ b/game/ato/flightplans/sweep.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +from datetime import timedelta +from typing import Iterator, TYPE_CHECKING, Type + +from dcs import Point + +from game.utils import Heading +from .ibuilder import IBuilder +from .loiter import LoiterFlightPlan +from .waypointbuilder import WaypointBuilder +from ..traveltime import GroundSpeed, TravelTime +from ...flightplan import HoldZoneGeometry + +if TYPE_CHECKING: + from ..flight import Flight + from ..flightwaypoint import FlightWaypoint + + +class Builder(IBuilder): + def build(self) -> SweepFlightPlan: + assert self.package.waypoints is not None + target = self.package.target.position + heading = Heading.from_degrees( + self.package.waypoints.join.heading_between_point(target) + ) + start_pos = target.point_from_heading( + heading.degrees, -self.doctrine.sweep_distance.meters + ) + + builder = WaypointBuilder(self.flight, self.coalition) + start, end = builder.sweep(start_pos, target, self.doctrine.ingress_altitude) + + hold = builder.hold(self._hold_point()) + + refuel = None + + if self.package.waypoints is not None: + refuel = builder.refuel(self.package.waypoints.refuel) + + return SweepFlightPlan( + flight=self.flight, + lead_time=timedelta(minutes=5), + departure=builder.takeoff(self.flight.departure), + hold=hold, + hold_duration=timedelta(minutes=5), + nav_to=builder.nav_path( + hold.position, start.position, self.doctrine.ingress_altitude + ), + nav_from=builder.nav_path( + end.position, + self.flight.arrival.position, + self.doctrine.ingress_altitude, + ), + sweep_start=start, + sweep_end=end, + refuel=refuel, + arrival=builder.land(self.flight.arrival), + divert=builder.divert(self.flight.divert), + bullseye=builder.bullseye(), + ) + + def _hold_point(self) -> Point: + assert self.package.waypoints is not None + origin = self.flight.departure.position + target = self.package.target.position + join = self.package.waypoints.join + ip = self.package.waypoints.ingress + return HoldZoneGeometry( + target, origin, ip, join, self.coalition, self.theater + ).find_best_hold_point() + + +class SweepFlightPlan(LoiterFlightPlan): + def __init__( + self, + flight: Flight, + departure: FlightWaypoint, + arrival: FlightWaypoint, + divert: FlightWaypoint | None, + bullseye: FlightWaypoint, + nav_to: list[FlightWaypoint], + nav_from: list[FlightWaypoint], + hold: FlightWaypoint, + hold_duration: timedelta, + sweep_start: FlightWaypoint, + sweep_end: FlightWaypoint, + refuel: FlightWaypoint, + lead_time: timedelta, + ) -> None: + super().__init__( + flight, + departure, + arrival, + divert, + bullseye, + nav_to, + nav_from, + hold, + hold_duration, + ) + self.sweep_start = sweep_start + self.sweep_end = sweep_end + self.refuel = refuel + self.lead_time = lead_time + + @staticmethod + def builder_type() -> Type[Builder]: + return Builder + + def iter_waypoints(self) -> Iterator[FlightWaypoint]: + yield self.departure + yield self.hold + yield from self.nav_to + yield self.sweep_start + yield self.sweep_end + if self.refuel is not None: + yield self.refuel + yield from self.nav_from + yield self.arrival + if self.divert is not None: + yield self.divert + yield self.bullseye + + @property + def combat_speed_waypoints(self) -> set[FlightWaypoint]: + return {self.sweep_end} + + @property + def tot_waypoint(self) -> FlightWaypoint | None: + return self.sweep_end + + @property + def tot_offset(self) -> timedelta: + return -self.lead_time + + @property + def sweep_start_time(self) -> timedelta: + travel_time = self.travel_time_between_waypoints( + self.sweep_start, self.sweep_end + ) + return self.sweep_end_time - travel_time + + @property + def sweep_end_time(self) -> timedelta: + return self.tot + + def tot_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None: + if waypoint == self.sweep_start: + return self.sweep_start_time + if waypoint == self.sweep_end: + return self.sweep_end_time + return None + + def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None: + if waypoint == self.hold: + return self.push_time + return None + + @property + def push_time(self) -> timedelta: + return self.sweep_end_time - TravelTime.between_points( + self.hold.position, + self.sweep_end.position, + GroundSpeed.for_flight(self.flight, self.hold.alt), + ) + + def mission_departure_time(self) -> timedelta: + return self.sweep_end_time diff --git a/game/ato/flightplans/tarcap.py b/game/ato/flightplans/tarcap.py new file mode 100644 index 00000000..cc7bca33 --- /dev/null +++ b/game/ato/flightplans/tarcap.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +import random +from collections.abc import Iterator +from datetime import timedelta +from typing import TYPE_CHECKING, Type + +from game.utils import Distance, Speed, feet +from .capbuilder import CapBuilder +from .patrolling import PatrollingFlightPlan +from .waypointbuilder import WaypointBuilder + +if TYPE_CHECKING: + from ..flight import Flight + from ..flightwaypoint import FlightWaypoint + + +class Builder(CapBuilder): + def build(self) -> TarCapFlightPlan: + location = self.package.target + + preferred_alt = self.flight.unit_type.preferred_patrol_altitude + randomized_alt = preferred_alt + feet(random.randint(-2, 1) * 1000) + patrol_alt = max( + self.doctrine.min_patrol_altitude, + min(self.doctrine.max_patrol_altitude, randomized_alt), + ) + patrol_speed = self.flight.unit_type.preferred_patrol_speed(patrol_alt) + + builder = WaypointBuilder(self.flight, self.coalition) + orbit0p, orbit1p = self.cap_racetrack_for_objective(location, barcap=False) + + start, end = builder.race_track(orbit0p, orbit1p, patrol_alt) + + refuel = None + + if self.package.waypoints is not None: + refuel = builder.refuel(self.package.waypoints.refuel) + + return TarCapFlightPlan( + flight=self.flight, + lead_time=timedelta(minutes=2), + # Note that this duration only has an effect if there are no + # flights in the package that have requested escort. If the package + # requests an escort the CAP self.flight will remain on station for the + # duration of the escorted mission, or until it is winchester/bingo. + patrol_duration=self.doctrine.cap_duration, + patrol_speed=patrol_speed, + engagement_distance=self.doctrine.cap_engagement_range, + departure=builder.takeoff(self.flight.departure), + nav_to=builder.nav_path( + self.flight.departure.position, orbit0p, patrol_alt + ), + nav_from=builder.nav_path( + orbit1p, self.flight.arrival.position, patrol_alt + ), + patrol_start=start, + patrol_end=end, + refuel=refuel, + arrival=builder.land(self.flight.arrival), + divert=builder.divert(self.flight.divert), + bullseye=builder.bullseye(), + ) + + +class TarCapFlightPlan(PatrollingFlightPlan): + def __init__( + self, + flight: Flight, + departure: FlightWaypoint, + arrival: FlightWaypoint, + divert: FlightWaypoint | None, + bullseye: FlightWaypoint, + nav_to: list[FlightWaypoint], + nav_from: list[FlightWaypoint], + patrol_start: FlightWaypoint, + patrol_end: FlightWaypoint, + patrol_duration: timedelta, + patrol_speed: Speed, + engagement_distance: Distance, + refuel: FlightWaypoint | None, + lead_time: timedelta, + ) -> None: + super().__init__( + flight, + departure, + arrival, + divert, + bullseye, + nav_to, + nav_from, + patrol_start, + patrol_end, + patrol_duration, + patrol_speed, + engagement_distance, + ) + self.refuel = refuel + self.lead_time = lead_time + + @staticmethod + def builder_type() -> Type[Builder]: + return Builder + + def iter_waypoints(self) -> Iterator[FlightWaypoint]: + yield self.departure + yield from self.nav_to + yield self.patrol_start + yield self.patrol_end + if self.refuel is not None: + yield self.refuel + yield from self.nav_from + yield self.arrival + if self.divert is not None: + yield self.divert + yield self.bullseye + + @property + def combat_speed_waypoints(self) -> set[FlightWaypoint]: + return {self.patrol_start, self.patrol_end} + + @property + def tot_offset(self) -> timedelta: + return -self.lead_time + + def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None: + if waypoint == self.patrol_end: + return self.patrol_end_time + return super().depart_time_for_waypoint(waypoint) + + @property + def patrol_start_time(self) -> timedelta: + start = self.package.escort_start_time + if start is not None: + return start + self.tot_offset + return self.tot + + @property + def patrol_end_time(self) -> timedelta: + end = self.package.escort_end_time + if end is not None: + return end + return super().patrol_end_time diff --git a/game/ato/flightplans/theaterrefueling.py b/game/ato/flightplans/theaterrefueling.py new file mode 100644 index 00000000..b9cb0e51 --- /dev/null +++ b/game/ato/flightplans/theaterrefueling.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from datetime import timedelta +from typing import Type + +from game.utils import Heading, feet, knots, meters, nautical_miles +from .ibuilder import IBuilder +from .patrolling import PatrollingFlightPlan +from .waypointbuilder import WaypointBuilder + + +class Builder(IBuilder): + def build(self) -> TheaterRefuelingFlightPlan: + racetrack_half_distance = nautical_miles(20).meters + + patrol_duration = timedelta(hours=1) + + location = self.package.target + + closest_boundary = self.threat_zones.closest_boundary(location.position) + heading_to_threat_boundary = Heading.from_degrees( + location.position.heading_between_point(closest_boundary) + ) + distance_to_threat = meters( + location.position.distance_to_point(closest_boundary) + ) + orbit_heading = heading_to_threat_boundary + + # Station 70nm outside the threat zone. + threat_buffer = nautical_miles(70) + if self.threat_zones.threatened(location.position): + orbit_distance = distance_to_threat + threat_buffer + else: + orbit_distance = distance_to_threat - threat_buffer + + racetrack_center = location.position.point_from_heading( + orbit_heading.degrees, orbit_distance.meters + ) + + racetrack_start = racetrack_center.point_from_heading( + orbit_heading.right.degrees, racetrack_half_distance + ) + + racetrack_end = racetrack_center.point_from_heading( + orbit_heading.left.degrees, racetrack_half_distance + ) + + builder = WaypointBuilder(self.flight, self.coalition) + + tanker_type = self.flight.unit_type + if tanker_type.patrol_altitude is not None: + altitude = tanker_type.patrol_altitude + else: + altitude = feet(21000) + + # TODO: Could use self.flight.unit_type.preferred_patrol_speed(altitude). + if tanker_type.patrol_speed is not None: + speed = tanker_type.patrol_speed + else: + # ~280 knots IAS at 21000. + speed = knots(400) + + racetrack = builder.race_track(racetrack_start, racetrack_end, altitude) + + return TheaterRefuelingFlightPlan( + flight=self.flight, + departure=builder.takeoff(self.flight.departure), + nav_to=builder.nav_path( + self.flight.departure.position, racetrack_start, altitude + ), + nav_from=builder.nav_path( + racetrack_end, self.flight.arrival.position, altitude + ), + patrol_start=racetrack[0], + patrol_end=racetrack[1], + arrival=builder.land(self.flight.arrival), + divert=builder.divert(self.flight.divert), + bullseye=builder.bullseye(), + patrol_duration=patrol_duration, + patrol_speed=speed, + # TODO: Factor out a common base of the combat and non-combat race-tracks. + # No harm in setting this, but we ought to clean up a bit. + engagement_distance=meters(0), + ) + + +class TheaterRefuelingFlightPlan(PatrollingFlightPlan): + @staticmethod + def builder_type() -> Type[Builder]: + return Builder diff --git a/game/ato/waypointbuilder.py b/game/ato/flightplans/waypointbuilder.py similarity index 99% rename from game/ato/waypointbuilder.py rename to game/ato/flightplans/waypointbuilder.py index aaa39110..c6ce57e0 100644 --- a/game/ato/waypointbuilder.py +++ b/game/ato/flightplans/waypointbuilder.py @@ -14,6 +14,8 @@ from typing import ( from dcs.mapping import Point, Vector2 +from game.ato.flightwaypoint import AltitudeReference, FlightWaypoint +from game.ato.flightwaypointtype import FlightWaypointType from game.theater import ( ControlPoint, MissionTarget, @@ -22,14 +24,12 @@ from game.theater import ( TheaterUnit, ) from game.utils import Distance, meters, nautical_miles -from .flightwaypoint import AltitudeReference, FlightWaypoint -from .flightwaypointtype import FlightWaypointType if TYPE_CHECKING: from game.coalition import Coalition from game.transfers import MultiGroupTransport from game.theater.theatergroup import TheaterGroup - from .flight import Flight + from game.ato.flight import Flight @dataclass(frozen=True) diff --git a/game/ato/package.py b/game/ato/package.py index 2f172c81..f7210572 100644 --- a/game/ato/package.py +++ b/game/ato/package.py @@ -6,10 +6,10 @@ from dataclasses import dataclass, field from datetime import timedelta from typing import Dict, List, Optional, TYPE_CHECKING +from .flightplans.formation import FormationFlightPlan from game.db import Database from game.utils import Speed from .flight import Flight -from .flightplan import FormationFlightPlan from .flighttype import FlightType from .packagewaypoints import PackageWaypoints from .traveltime import TotEstimator diff --git a/game/commander/packagefulfiller.py b/game/commander/packagefulfiller.py index 7371f1f4..c09a0293 100644 --- a/game/commander/packagefulfiller.py +++ b/game/commander/packagefulfiller.py @@ -5,6 +5,8 @@ from collections import defaultdict from typing import Dict, Iterable, Optional, Set, TYPE_CHECKING from game.ato.airtaaskingorder import AirTaskingOrder +from game.ato.closestairfields import ObjectiveDistanceCache +from game.ato.flightplans.flightplanbuilder import FlightPlanBuilder from game.ato.flighttype import FlightType from game.ato.package import Package from game.commander.missionproposals import EscortType, ProposedFlight, ProposedMission @@ -17,8 +19,6 @@ from game.settings import Settings from game.squadrons import AirWing from game.theater import ConflictTheater from game.threatzones import ThreatZones -from game.ato.closestairfields import ObjectiveDistanceCache -from game.ato.flightplan import FlightPlanBuilder if TYPE_CHECKING: from game.ato import Flight diff --git a/game/missiongenerator/aircraft/aircraftbehavior.py b/game/missiongenerator/aircraft/aircraftbehavior.py index 334d1d8f..45cf69b6 100644 --- a/game/missiongenerator/aircraft/aircraftbehavior.py +++ b/game/missiongenerator/aircraft/aircraftbehavior.py @@ -23,7 +23,8 @@ from dcs.task import ( from dcs.unitgroup import FlyingGroup from game.ato import Flight, FlightType -from game.ato.flightplan import AwacsFlightPlan, RefuelingFlightPlan +from game.ato.flightplans.aewc import AewcFlightPlan +from game.ato.flightplans.theaterrefueling import TheaterRefuelingFlightPlan class AircraftBehavior: @@ -221,7 +222,7 @@ class AircraftBehavior: def configure_awacs(self, group: FlyingGroup[Any], flight: Flight) -> None: group.task = AWACS.name - if not isinstance(flight.flight_plan, AwacsFlightPlan): + if not isinstance(flight.flight_plan, AewcFlightPlan): logging.error( f"Cannot configure AEW&C tasks for {flight} because it does not have " "an AEW&C flight plan." @@ -242,7 +243,7 @@ class AircraftBehavior: def configure_refueling(self, group: FlyingGroup[Any], flight: Flight) -> None: group.task = Refueling.name - if not isinstance(flight.flight_plan, RefuelingFlightPlan): + if not isinstance(flight.flight_plan, TheaterRefuelingFlightPlan): logging.error( f"Cannot configure racetrack refueling tasks for {flight} because it " "does not have an racetrack refueling flight plan." diff --git a/game/missiongenerator/aircraft/flightgroupconfigurator.py b/game/missiongenerator/aircraft/flightgroupconfigurator.py index a06715bb..3e244c0c 100644 --- a/game/missiongenerator/aircraft/flightgroupconfigurator.py +++ b/game/missiongenerator/aircraft/flightgroupconfigurator.py @@ -18,11 +18,12 @@ from game.radio.radios import RadioFrequency, RadioRegistry from game.radio.tacan import TacanBand, TacanRegistry, TacanUsage from game.runways import RunwayData from game.squadrons import Pilot -from game.ato.flightplan import AwacsFlightPlan, RefuelingFlightPlan from .aircraftbehavior import AircraftBehavior from .aircraftpainter import AircraftPainter from .flightdata import FlightData from .waypoints import WaypointGenerator +from ...ato.flightplans.aewc import AewcFlightPlan +from ...ato.flightplans.theaterrefueling import TheaterRefuelingFlightPlan if TYPE_CHECKING: from game import Game @@ -128,7 +129,7 @@ class FlightGroupConfigurator: def register_air_support(self, channel: RadioFrequency) -> None: callsign = callsign_for_support_unit(self.group) - if isinstance(self.flight.flight_plan, AwacsFlightPlan): + if isinstance(self.flight.flight_plan, AewcFlightPlan): self.air_support.awacs.append( AwacsInfo( group_name=str(self.group.name), @@ -136,11 +137,11 @@ class FlightGroupConfigurator: freq=channel, depature_location=self.flight.departure.name, end_time=self.flight.flight_plan.mission_departure_time, - start_time=self.flight.flight_plan.mission_start_time, + start_time=self.flight.flight_plan.takeoff_time(), blue=self.flight.departure.captured, ) ) - elif isinstance(self.flight.flight_plan, RefuelingFlightPlan): + elif isinstance(self.flight.flight_plan, TheaterRefuelingFlightPlan): tacan = self.tacan_registry.alloc_for_band(TacanBand.Y, TacanUsage.AirToAir) self.air_support.tankers.append( TankerInfo( diff --git a/game/missiongenerator/aircraft/waypoints/casingress.py b/game/missiongenerator/aircraft/waypoints/casingress.py index e167673f..55a25ebc 100644 --- a/game/missiongenerator/aircraft/waypoints/casingress.py +++ b/game/missiongenerator/aircraft/waypoints/casingress.py @@ -3,8 +3,8 @@ import logging from dcs.point import MovingPoint from dcs.task import EngageTargets, EngageTargetsInZone, Targets +from game.ato.flightplans.cas import CasFlightPlan from game.utils import nautical_miles -from game.ato.flightplan import CasFlightPlan from .pydcswaypointbuilder import PydcsWaypointBuilder diff --git a/game/missiongenerator/aircraft/waypoints/holdpoint.py b/game/missiongenerator/aircraft/waypoints/holdpoint.py index c2a7b0ec..3e116c92 100644 --- a/game/missiongenerator/aircraft/waypoints/holdpoint.py +++ b/game/missiongenerator/aircraft/waypoints/holdpoint.py @@ -3,7 +3,7 @@ import logging from dcs.point import MovingPoint from dcs.task import ControlledTask, OptFormation, OrbitAction -from game.ato.flightplan import LoiterFlightPlan +from game.ato.flightplans.loiter import LoiterFlightPlan from .pydcswaypointbuilder import PydcsWaypointBuilder diff --git a/game/missiongenerator/aircraft/waypoints/racetrack.py b/game/missiongenerator/aircraft/waypoints/racetrack.py index 2b35c447..6afc05e0 100644 --- a/game/missiongenerator/aircraft/waypoints/racetrack.py +++ b/game/missiongenerator/aircraft/waypoints/racetrack.py @@ -11,7 +11,7 @@ from dcs.task import ( ) from game.ato import FlightType -from game.ato.flightplan import PatrollingFlightPlan +from game.ato.flightplans.patrolling import PatrollingFlightPlan from .pydcswaypointbuilder import PydcsWaypointBuilder diff --git a/game/missiongenerator/aircraft/waypoints/racetrackend.py b/game/missiongenerator/aircraft/waypoints/racetrackend.py index 5e4dab3a..c1744445 100644 --- a/game/missiongenerator/aircraft/waypoints/racetrackend.py +++ b/game/missiongenerator/aircraft/waypoints/racetrackend.py @@ -2,7 +2,7 @@ import logging from dcs.point import MovingPoint -from game.ato.flightplan import PatrollingFlightPlan +from game.ato.flightplans.patrolling import PatrollingFlightPlan from .pydcswaypointbuilder import PydcsWaypointBuilder diff --git a/game/missiongenerator/aircraft/waypoints/sweepingress.py b/game/missiongenerator/aircraft/waypoints/sweepingress.py index 186d8ee3..c4e78909 100644 --- a/game/missiongenerator/aircraft/waypoints/sweepingress.py +++ b/game/missiongenerator/aircraft/waypoints/sweepingress.py @@ -3,8 +3,8 @@ import logging from dcs.point import MovingPoint from dcs.task import EngageTargets, OptFormation, Targets +from game.ato.flightplans.sweep import SweepFlightPlan from game.utils import nautical_miles -from game.ato.flightplan import SweepFlightPlan from .pydcswaypointbuilder import PydcsWaypointBuilder diff --git a/game/server/flights/routes.py b/game/server/flights/routes.py index 7ffe1680..4ec0684f 100644 --- a/game/server/flights/routes.py +++ b/game/server/flights/routes.py @@ -4,7 +4,8 @@ from fastapi import APIRouter, Depends from shapely.geometry import LineString, Point as ShapelyPoint from game import Game -from game.ato.flightplan import CasFlightPlan, PatrollingFlightPlan +from game.ato.flightplans.patrolling import PatrollingFlightPlan +from game.ato.flightplans.cas import CasFlightPlan from game.server import GameContext from game.server.flights.models import FlightJs from game.server.leaflet import LeafletPoly, ShapelyUtil diff --git a/game/squadrons/squadron.py b/game/squadrons/squadron.py index 510f6ea3..c82566e0 100644 --- a/game/squadrons/squadron.py +++ b/game/squadrons/squadron.py @@ -9,9 +9,9 @@ from typing import Optional, Sequence, TYPE_CHECKING from faker import Faker from game.ato import Flight, FlightType, Package -from game.ato.flightplan import FlightPlanBuilder from game.settings import AutoAtoBehavior, Settings from .pilot import Pilot, PilotStatus +from ..ato.flightplans.flightplanbuilder import FlightPlanBuilder from ..db.database import Database from ..utils import meters diff --git a/game/transfers.py b/game/transfers.py index a783845c..788565ba 100644 --- a/game/transfers.py +++ b/game/transfers.py @@ -43,7 +43,7 @@ from dcs.mapping import Point from game.ato.ai_flight_planner_db import aircraft_for_task from game.ato.closestairfields import ObjectiveDistanceCache from game.ato.flight import Flight -from game.ato.flightplan import FlightPlanBuilder +from game.ato.flightplans.flightplanbuilder import FlightPlanBuilder from game.ato.flighttype import FlightType from game.ato.package import Package from game.dcs.aircrafttype import AircraftType diff --git a/qt_ui/windows/mission/QPackageDialog.py b/qt_ui/windows/mission/QPackageDialog.py index 4cd36a32..92621875 100644 --- a/qt_ui/windows/mission/QPackageDialog.py +++ b/qt_ui/windows/mission/QPackageDialog.py @@ -16,7 +16,8 @@ from PySide2.QtWidgets import ( ) from game.ato.flight import Flight -from game.ato.flightplan import FlightPlanBuilder, PlanningError +from game.ato.flightplans.flightplanbuilder import FlightPlanBuilder +from game.ato.flightplans.planningerror import PlanningError from game.ato.package import Package from game.game import Game from game.server import EventStream diff --git a/qt_ui/windows/mission/flight/settings/FlightAirfieldDisplay.py b/qt_ui/windows/mission/flight/settings/FlightAirfieldDisplay.py index 51a38e37..eec845c9 100644 --- a/qt_ui/windows/mission/flight/settings/FlightAirfieldDisplay.py +++ b/qt_ui/windows/mission/flight/settings/FlightAirfieldDisplay.py @@ -4,7 +4,8 @@ from PySide2.QtWidgets import QGroupBox, QLabel, QMessageBox, QVBoxLayout from game import Game from game.ato.flight import Flight -from game.ato.flightplan import FlightPlanBuilder, PlanningError +from game.ato.flightplans.flightplanbuilder import FlightPlanBuilder +from game.ato.flightplans.planningerror import PlanningError from game.ato.traveltime import TotEstimator from qt_ui.models import PackageModel from qt_ui.widgets.QLabeledWidget import QLabeledWidget diff --git a/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py b/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py index 70057397..36646445 100644 --- a/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py +++ b/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py @@ -12,17 +12,15 @@ from PySide2.QtWidgets import ( ) from game import Game -from game.ato.package import Package +from game.ato.flight import Flight +from game.ato.flightplans.custom import CustomFlightPlan +from game.ato.flightplans.flightplanbuilder import FlightPlanBuilder +from game.ato.flightplans.formationattack import FormationAttackFlightPlan +from game.ato.flightplans.planningerror import PlanningError from game.ato.flighttype import FlightType from game.ato.flightwaypoint import FlightWaypoint -from game.ato.flight import Flight -from game.ato.flightplan import ( - CustomFlightPlan, - FlightPlanBuilder, - PlanningError, - StrikeFlightPlan, -) from game.ato.loadouts import Loadout +from game.ato.package import Package from qt_ui.windows.mission.flight.waypoints.QFlightWaypointList import ( QFlightWaypointList, ) @@ -107,7 +105,7 @@ class QFlightWaypointTab(QFrame): # Need to degrade to a custom flight plan and remove the waypoint. # If the waypoint is a target waypoint and is not the last target # waypoint, we don't need to degrade. - if isinstance(self.flight.flight_plan, StrikeFlightPlan): + if isinstance(self.flight.flight_plan, FormationAttackFlightPlan): is_target = waypoint in self.flight.flight_plan.targets if is_target and len(self.flight.flight_plan.targets) > 1: self.flight.flight_plan.targets.remove(waypoint) @@ -144,9 +142,8 @@ class QFlightWaypointTab(QFrame): def degrade_to_custom_flight_plan(self) -> None: if not isinstance(self.flight.flight_plan, CustomFlightPlan): self.flight.flight_plan = CustomFlightPlan( - package=self.flight.package, flight=self.flight, - custom_waypoints=self.flight.flight_plan.waypoints, + waypoints=self.flight.flight_plan.waypoints, ) def confirm_recreate(self, task: FlightType) -> None: