From d369ce8847049cdb639ed5fcb86505edb7813b80 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Sun, 15 Nov 2020 23:12:38 -0800 Subject: [PATCH] Add fighter sweep tasks. Fighter sweeps arrive at the target ahead of the rest of the package (currently a fixed 5 minute lead) to clear out enemy fighters and then RTB. Fixes https://github.com/Khopa/dcs_liberation/issues/348 --- changelog.md | 5 + game/data/doctrine.py | 5 + gen/aircraft.py | 50 ++++++-- gen/ato.py | 1 + gen/flights/flight.py | 3 + gen/flights/flightplan.py | 131 ++++++++++++++++++-- gen/flights/traveltime.py | 12 +- gen/flights/waypointbuilder.py | 50 ++++++++ qt_ui/widgets/combos/QFlightTypeComboBox.py | 1 + 9 files changed, 237 insertions(+), 21 deletions(-) diff --git a/changelog.md b/changelog.md index b5b938aa..bbb34464 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,8 @@ +# 2.3.0 + +# Features/Improvements +* **[Flight Planner]** Added fighter sweep missions. + # 2.2.1 # Features/Improvements diff --git a/game/data/doctrine.py b/game/data/doctrine.py index 99bb254a..fce67b1b 100644 --- a/game/data/doctrine.py +++ b/game/data/doctrine.py @@ -36,6 +36,8 @@ class Doctrine: cas_duration: timedelta + sweep_distance: int + MODERN_DOCTRINE = Doctrine( cap=True, @@ -62,6 +64,7 @@ MODERN_DOCTRINE = Doctrine( cap_min_distance_from_cp=nm_to_meter(10), cap_max_distance_from_cp=nm_to_meter(40), cas_duration=timedelta(minutes=30), + sweep_distance=nm_to_meter(60), ) COLDWAR_DOCTRINE = Doctrine( @@ -89,6 +92,7 @@ COLDWAR_DOCTRINE = Doctrine( cap_min_distance_from_cp=nm_to_meter(8), cap_max_distance_from_cp=nm_to_meter(25), cas_duration=timedelta(minutes=30), + sweep_distance=nm_to_meter(40), ) WWII_DOCTRINE = Doctrine( @@ -116,4 +120,5 @@ WWII_DOCTRINE = Doctrine( cap_min_distance_from_cp=nm_to_meter(0), cap_max_distance_from_cp=nm_to_meter(5), cas_duration=timedelta(minutes=30), + sweep_distance=nm_to_meter(10), ) diff --git a/gen/aircraft.py b/gen/aircraft.py index 7c4eac80..9ef18fec 100644 --- a/gen/aircraft.py +++ b/gen/aircraft.py @@ -5,7 +5,7 @@ import random from dataclasses import dataclass from datetime import timedelta from functools import cached_property -from typing import Dict, List, Optional, Type, Union, TYPE_CHECKING +from typing import Dict, List, Optional, TYPE_CHECKING, Type, Union from dcs import helicopters from dcs.action import AITaskPush, ActivateGroup @@ -13,10 +13,12 @@ from dcs.condition import CoalitionHasAirdrome, TimeAfter from dcs.country import Country from dcs.flyingunit import FlyingUnit from dcs.helicopters import UH_1H, helicopter_map +from dcs.mapping import Point from dcs.mission import Mission, StartType from dcs.planes import ( AJS37, B_17G, + B_52H, Bf_109K_4, FW_190A8, FW_190D9, @@ -31,7 +33,8 @@ from dcs.planes import ( P_51D_30_NA, SpitfireLFMkIX, SpitfireLFMkIXCW, - Su_33, A_20G, Tu_22M3, B_52H, + Su_33, + Tu_22M3, ) from dcs.point import MovingPoint, PointAction from dcs.task import ( @@ -49,10 +52,8 @@ from dcs.task import ( OptRTBOnBingoFuel, OptRTBOnOutOfAmmo, OptReactOnThreat, - OptRestrictAfterburner, OptRestrictJettison, OrbitAction, - PinpointStrike, SEAD, StartCommand, Targets, @@ -71,6 +72,7 @@ from game.utils import nm_to_meter from gen.airsupportgen import AirSupport from gen.ato import AirTaskingOrder, Package from gen.callsigns import create_group_callsign_from_unit +from gen.conflictgen import FRONTLINE_LENGTH from gen.flights.flight import ( Flight, FlightType, @@ -79,15 +81,14 @@ from gen.flights.flight import ( ) from gen.radios import MHz, Radio, RadioFrequency, RadioRegistry, get_radio from gen.runways import RunwayData -from gen.conflictgen import FRONTLINE_LENGTH -from dcs.mapping import Point from theater import TheaterGroundObject from theater.controlpoint import ControlPoint, ControlPointType from .conflictgen import Conflict from .flights.flightplan import ( CasFlightPlan, - FormationFlightPlan, + LoiterFlightPlan, PatrollingFlightPlan, + SweepFlightPlan, ) from .flights.traveltime import TotEstimator from .naming import namegen @@ -1035,9 +1036,6 @@ class AircraftConflictGenerator: self.configure_behavior(group, rtb_winchester=ammo_type) - group.points[0].tasks.append(EngageTargets(max_distance=nm_to_meter(50), - targets=[Targets.All.Air])) - def configure_cas(self, group: FlyingGroup, package: Package, flight: Flight, dynamic_runways: Dict[str, RunwayData]) -> None: @@ -1118,7 +1116,7 @@ class AircraftConflictGenerator: dynamic_runways: Dict[str, RunwayData]) -> None: flight_type = flight.flight_type if flight_type in [FlightType.BARCAP, FlightType.TARCAP, - FlightType.INTERCEPTION]: + FlightType.INTERCEPTION, FlightType.SWEEP]: self.configure_cap(group, package, flight, dynamic_runways) elif flight_type in [FlightType.CAS, FlightType.BAI]: self.configure_cas(group, package, flight, dynamic_runways) @@ -1278,6 +1276,7 @@ class PydcsWaypointBuilder: FlightWaypointType.LANDING_POINT: LandingPointBuilder, FlightWaypointType.LOITER: HoldPointBuilder, FlightWaypointType.PATROL_TRACK: RaceTrackBuilder, + FlightWaypointType.INGRESS_SWEEP: SweepIngressBuilder, } builder = builders.get(waypoint.waypoint_type, DefaultWaypointBuilder) return builder(waypoint, group, package, flight, mission) @@ -1314,7 +1313,7 @@ class HoldPointBuilder(PydcsWaypointBuilder): altitude=waypoint.alt, pattern=OrbitAction.OrbitPattern.Circle )) - if not isinstance(self.flight.flight_plan, FormationFlightPlan): + if not isinstance(self.flight.flight_plan, LoiterFlightPlan): flight_plan_type = self.flight.flight_plan.__class__.__name__ logging.error( f"Cannot configure hold for for {self.flight} because " @@ -1458,6 +1457,23 @@ class StrikeIngressBuilder(PydcsWaypointBuilder): return waypoint +class SweepIngressBuilder(PydcsWaypointBuilder): + def build(self) -> MovingPoint: + waypoint = super().build() + + if not isinstance(self.flight.flight_plan, SweepFlightPlan): + flight_plan_type = self.flight.flight_plan.__class__.__name__ + logging.error( + f"Cannot create sweep for {self.flight} because " + f"{flight_plan_type} is not a sweep flight plan.") + return waypoint + + waypoint.tasks.append(EngageTargets(max_distance=nm_to_meter(50), + targets=[Targets.All.Air])) + + return waypoint + + class JoinPointBuilder(PydcsWaypointBuilder): def build(self) -> MovingPoint: waypoint = super().build() @@ -1532,4 +1548,14 @@ class RaceTrackBuilder(PydcsWaypointBuilder): racetrack.stop_after_time( int(self.flight.flight_plan.patrol_end_time.total_seconds())) waypoint.add_task(racetrack) + + # TODO: Move the properties of this task into the flight plan? + # CAP is the only current user of this so it's not a big deal, but might + # be good to make this usable for things like BAI when we add that + # later. + cap_types = {FlightType.BARCAP, FlightType.TARCAP} + if self.flight.flight_type in cap_types: + waypoint.tasks.append(EngageTargets(max_distance=nm_to_meter(50), + targets=[Targets.All.Air])) + return waypoint diff --git a/gen/ato.py b/gen/ato.py index d814e5ee..a21563dc 100644 --- a/gen/ato.py +++ b/gen/ato.py @@ -159,6 +159,7 @@ class Package: FlightType.TARCAP, FlightType.CAP, FlightType.BARCAP, + FlightType.SWEEP, FlightType.EWAR, FlightType.ESCORT, ] diff --git a/gen/flights/flight.py b/gen/flights/flight.py index 2462a0a5..a19d362c 100644 --- a/gen/flights/flight.py +++ b/gen/flights/flight.py @@ -38,6 +38,8 @@ class FlightType(Enum): RECON = 15 EWAR = 16 + SWEEP = 17 + class FlightWaypointType(Enum): TAKEOFF = 0 # Take off point @@ -61,6 +63,7 @@ class FlightWaypointType(Enum): LOITER = 18 INGRESS_ESCORT = 19 INGRESS_DEAD = 20 + INGRESS_SWEEP = 21 class FlightWaypoint: diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py index c73eb3ae..ed6561f5 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -105,6 +105,15 @@ class FlightPlan: """ raise NotImplementedError + @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() + # Not cached because changes to the package might alter the formation speed. @property def travel_time_to_target(self) -> Optional[timedelta]: @@ -147,8 +156,33 @@ class FlightPlan: @dataclass(frozen=True) -class FormationFlightPlan(FlightPlan): +class LoiterFlightPlan(FlightPlan): hold: FlightWaypoint + + @property + def waypoints(self) -> List[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 + + +@dataclass(frozen=True) +class FormationFlightPlan(LoiterFlightPlan): join: FlightWaypoint split: FlightWaypoint @@ -215,12 +249,6 @@ class FormationFlightPlan(FlightPlan): return self.split_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.join_time - TravelTime.between_points( @@ -461,6 +489,64 @@ class StrikeFlightPlan(FormationFlightPlan): return super().tot_for_waypoint(waypoint) +@dataclass(frozen=True) +class SweepFlightPlan(LoiterFlightPlan): + takeoff: FlightWaypoint + sweep_start: FlightWaypoint + sweep_end: FlightWaypoint + land: FlightWaypoint + lead_time: timedelta + + @property + def waypoints(self) -> List[FlightWaypoint]: + return [ + self.takeoff, + self.hold, + self.sweep_start, + self.sweep_end, + self.land, + ] + + @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.package.time_over_target + self.tot_offset + + 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) + ) + + @dataclass(frozen=True) class CustomFlightPlan(FlightPlan): custom_waypoints: List[FlightWaypoint] @@ -546,6 +632,8 @@ class FlightPlanBuilder: return self.generate_sead(flight, custom_targets) 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_frontline_cap(flight) elif task == FlightType.TROOP_TRANSPORT: @@ -671,6 +759,35 @@ class FlightPlanBuilder: land=land ) + def generate_sweep(self, flight: Flight) -> SweepFlightPlan: + """Generate a BARCAP flight at a given location. + + Args: + flight: The flight to generate the flight plan for. + """ + target = self.package.target.position + + heading = self._heading_to_package_airfield(target) + start = target.point_from_heading(heading, + -self.doctrine.sweep_distance) + + builder = WaypointBuilder(self.game.conditions, flight, self.doctrine) + descent, land = builder.rtb(flight.from_cp) + + start, end = builder.sweep(start, target, + self.doctrine.ingress_altitude) + + return SweepFlightPlan( + package=self.package, + flight=flight, + lead_time=timedelta(minutes=5), + takeoff=builder.takeoff(flight.from_cp), + hold=builder.hold(self._hold_point(flight)), + sweep_start=start, + sweep_end=end, + land=land + ) + def generate_frontline_cap(self, flight: Flight) -> FrontLineCapFlightPlan: """Generate a CAP flight plan for the given front line. diff --git a/gen/flights/traveltime.py b/gen/flights/traveltime.py index ee9a6c7e..2a75afe3 100644 --- a/gen/flights/traveltime.py +++ b/gen/flights/traveltime.py @@ -128,7 +128,11 @@ class TotEstimator: f"time for {flight} will be immediate.") return timedelta() else: - tot = self.package.time_over_target + tot_waypoint = flight.flight_plan.tot_waypoint + if tot_waypoint is None: + tot = self.package.time_over_target + else: + tot = flight.flight_plan.tot_for_waypoint(tot_waypoint) return tot - travel_time - self.HOLD_TIME def earliest_tot(self) -> timedelta: @@ -165,9 +169,13 @@ class TotEstimator: # Return 0 so this flight's travel time does not affect the rest # of the package. return timedelta() + # Account for TOT offsets for the flight plan. An offset of -2 minutes + # means the flight's TOT is 2 minutes ahead of the package's so it needs + # an extra two minutes. + offset = -flight.flight_plan.tot_offset startup = self.estimate_startup(flight) ground_ops = self.estimate_ground_ops(flight) - return startup + ground_ops + time_to_target + return startup + ground_ops + time_to_target + offset @staticmethod def estimate_startup(flight: Flight) -> timedelta: diff --git a/gen/flights/waypointbuilder.py b/gen/flights/waypointbuilder.py index ddc76b5f..dd82f1aa 100644 --- a/gen/flights/waypointbuilder.py +++ b/gen/flights/waypointbuilder.py @@ -326,6 +326,56 @@ class WaypointBuilder: return (self.race_track_start(start, altitude), self.race_track_end(end, altitude)) + @staticmethod + def sweep_start(position: Point, altitude: int) -> FlightWaypoint: + """Creates a sweep start waypoint. + + Args: + position: Position of the waypoint. + altitude: Altitude of the sweep in meters. + """ + waypoint = FlightWaypoint( + FlightWaypointType.INGRESS_SWEEP, + position.x, + position.y, + altitude + ) + waypoint.name = "SWEEP START" + waypoint.description = "Proceed to the target and engage enemy aircraft" + waypoint.pretty_name = "Sweep start" + return waypoint + + @staticmethod + def sweep_end(position: Point, altitude: int) -> FlightWaypoint: + """Creates a sweep end waypoint. + + Args: + position: Position of the waypoint. + altitude: Altitude of the sweep in meters. + """ + waypoint = FlightWaypoint( + FlightWaypointType.EGRESS, + position.x, + position.y, + altitude + ) + waypoint.name = "SWEEP END" + waypoint.description = "End of sweep" + waypoint.pretty_name = "Sweep end" + return waypoint + + def sweep(self, start: Point, end: Point, + altitude: int) -> Tuple[FlightWaypoint, FlightWaypoint]: + """Creates two waypoint for a racetrack orbit. + + Args: + start: The beginning of the sweep. + end: The end of the sweep. + altitude: The sweep altitude. + """ + return (self.sweep_start(start, altitude), + self.sweep_end(end, altitude)) + def rtb(self, arrival: ControlPoint) -> Tuple[FlightWaypoint, FlightWaypoint]: """Creates descent ant landing waypoints for the given control point. diff --git a/qt_ui/widgets/combos/QFlightTypeComboBox.py b/qt_ui/widgets/combos/QFlightTypeComboBox.py index d1a27382..c1b42ccc 100644 --- a/qt_ui/widgets/combos/QFlightTypeComboBox.py +++ b/qt_ui/widgets/combos/QFlightTypeComboBox.py @@ -21,6 +21,7 @@ class QFlightTypeComboBox(QComboBox): FlightType.ESCORT, FlightType.SEAD, FlightType.DEAD, + FlightType.SWEEP, # TODO: FlightType.ELINT, # TODO: FlightType.EWAR, # TODO: FlightType.RECON,