diff --git a/changelog.md b/changelog.md index e418b984..eee13cd5 100644 --- a/changelog.md +++ b/changelog.md @@ -5,6 +5,7 @@ Saves from 5.x are not compatible with 6.0. ## Features/Improvements * **[Mission Generation]** Added an option to fast-forward mission generation until the point of first contact (WIP). +* **[Flight Planning]** Added the ability to plan tankers for recovery on package flights. AI does not plan. ## Fixes diff --git a/game/ato/flightwaypointtype.py b/game/ato/flightwaypointtype.py index bee6bfdb..fb2bd58c 100644 --- a/game/ato/flightwaypointtype.py +++ b/game/ato/flightwaypointtype.py @@ -45,3 +45,4 @@ class FlightWaypointType(Enum): PICKUP = 26 DROP_OFF = 27 BULLSEYE = 28 + REFUEL = 29 # Should look for nearby tanker to refuel from. diff --git a/game/ato/package.py b/game/ato/package.py index 5c509cd2..43788cd5 100644 --- a/game/ato/package.py +++ b/game/ato/package.py @@ -48,7 +48,7 @@ class Package: """The speed of the package when in formation. If none of the flights in the package will join a formation, this - returns None. This is nto uncommon, since only strike-like (strike, + returns None. This is not uncommon, since only strike-like (strike, DEAD, anti-ship, BAI, etc.) flights and their escorts fly in formation. Others (CAP and CAS, currently) will coordinate in target timing but fly their own path to the target. diff --git a/game/ato/packagewaypoints.py b/game/ato/packagewaypoints.py index 585046d7..e478b607 100644 --- a/game/ato/packagewaypoints.py +++ b/game/ato/packagewaypoints.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from typing import Optional from dcs import Point @@ -8,3 +9,4 @@ class PackageWaypoints: join: Point ingress: Point split: Point + refuel: Point diff --git a/game/flightplan/refuelzonegeometry.py b/game/flightplan/refuelzonegeometry.py new file mode 100644 index 00000000..ca3d32fc --- /dev/null +++ b/game/flightplan/refuelzonegeometry.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from dcs import Point + +if TYPE_CHECKING: + from game.coalition import Coalition + + +class RefuelZoneGeometry: + def __init__( + self, + package_home: Point, + join: Point, + coalition: Coalition, + ) -> None: + self.package_home = package_home + self.join = join + self.coalition = coalition + + def find_best_refuel_point(self) -> Point: + # Do simple at first. + # TODO: Consider threats. + distance = 0.75 * self.package_home.distance_to_point(self.join) + heading = self.package_home.heading_between_point(self.join) + return self.package_home.point_from_heading(heading, distance) diff --git a/game/missiongenerator/aircraft/waypoints/refuel.py b/game/missiongenerator/aircraft/waypoints/refuel.py new file mode 100644 index 00000000..c87036f8 --- /dev/null +++ b/game/missiongenerator/aircraft/waypoints/refuel.py @@ -0,0 +1,9 @@ +from dcs.point import MovingPoint +from dcs.task import RefuelingTaskAction +from .pydcswaypointbuilder import PydcsWaypointBuilder + + +class RefuelPointBuilder(PydcsWaypointBuilder): + def add_tasks(self, waypoint: MovingPoint) -> None: + waypoint.add_task(RefuelingTaskAction()) + return super().add_tasks(waypoint) diff --git a/game/missiongenerator/aircraft/waypoints/waypointgenerator.py b/game/missiongenerator/aircraft/waypoints/waypointgenerator.py index f731621d..517d1049 100644 --- a/game/missiongenerator/aircraft/waypoints/waypointgenerator.py +++ b/game/missiongenerator/aircraft/waypoints/waypointgenerator.py @@ -32,6 +32,7 @@ from .ocarunwayingress import OcaRunwayIngressBuilder from .pydcswaypointbuilder import PydcsWaypointBuilder, TARGET_WAYPOINTS from .racetrack import RaceTrackBuilder from .racetrackend import RaceTrackEndBuilder +from .refuel import RefuelPointBuilder from .seadingress import SeadIngressBuilder from .strikeingress import StrikeIngressBuilder from .sweepingress import SweepIngressBuilder @@ -130,6 +131,7 @@ class WaypointGenerator: FlightWaypointType.PATROL: RaceTrackEndBuilder, FlightWaypointType.PATROL_TRACK: RaceTrackBuilder, FlightWaypointType.PICKUP: CargoStopBuilder, + FlightWaypointType.REFUEL: RefuelPointBuilder, } builder = builders.get(waypoint.waypoint_type, DefaultWaypointBuilder) return builder( diff --git a/game/theater/controlpoint.py b/game/theater/controlpoint.py index 9c7e23ee..3e86a3bc 100644 --- a/game/theater/controlpoint.py +++ b/game/theater/controlpoint.py @@ -906,11 +906,12 @@ class Airfield(ControlPoint): if self.is_friendly(for_player): yield from [ FlightType.AEWC, - FlightType.REFUELING, # TODO: FlightType.INTERCEPTION # TODO: FlightType.LOGISTICS ] + yield FlightType.REFUELING + @property def total_aircraft_parking(self) -> int: """ diff --git a/game/theater/theatergroundobject.py b/game/theater/theatergroundobject.py index adea3fc5..0b8ffa7b 100644 --- a/game/theater/theatergroundobject.py +++ b/game/theater/theatergroundobject.py @@ -132,6 +132,7 @@ class TheaterGroundObject(MissionTarget, Generic[GroupT]): yield from [ FlightType.STRIKE, FlightType.BAI, + FlightType.REFUELING, ] yield from super().mission_types(for_player) diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py index a5b4cb81..6038b7c1 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -26,6 +26,7 @@ from game.ato.starttype import StartType 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, @@ -51,7 +52,6 @@ if TYPE_CHECKING: from game.ato.package import Package from game.coalition import Coalition from game.threatzones import ThreatZones - from game.transfers import Convoy INGRESS_TYPES = { @@ -362,6 +362,7 @@ class LoiterFlightPlan(FlightPlan): class FormationFlightPlan(LoiterFlightPlan): join: FlightWaypoint split: FlightWaypoint + refuel: FlightWaypoint def iter_waypoints(self) -> Iterator[FlightWaypoint]: raise NotImplementedError @@ -555,6 +556,7 @@ class CasFlightPlan(PatrollingFlightPlan): @dataclass(frozen=True) class TarCapFlightPlan(PatrollingFlightPlan): takeoff: FlightWaypoint + refuel: Optional[FlightWaypoint] land: FlightWaypoint divert: Optional[FlightWaypoint] bullseye: FlightWaypoint @@ -567,6 +569,8 @@ class TarCapFlightPlan(PatrollingFlightPlan): 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: @@ -624,6 +628,8 @@ class StrikeFlightPlan(FormationFlightPlan): 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: @@ -697,8 +703,20 @@ class StrikeFlightPlan(FormationFlightPlan): @property def split_time(self) -> timedelta: - travel_time = self.travel_time_between_waypoints(self.ingress, self.split) - return self.ingress_time + travel_time + 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: @@ -722,6 +740,7 @@ class SweepFlightPlan(LoiterFlightPlan): nav_to: List[FlightWaypoint] sweep_start: FlightWaypoint sweep_end: FlightWaypoint + refuel: FlightWaypoint nav_from: List[FlightWaypoint] land: FlightWaypoint divert: Optional[FlightWaypoint] @@ -734,6 +753,8 @@ class SweepFlightPlan(LoiterFlightPlan): 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: @@ -835,6 +856,10 @@ class RefuelingFlightPlan(PatrollingFlightPlan): 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 @@ -847,6 +872,52 @@ class RefuelingFlightPlan(PatrollingFlightPlan): yield self.bullseye +@dataclass(frozen=True) +class PackageRefuelingFlightPlan(RefuelingFlightPlan): + def target_area_waypoint(self) -> FlightWaypoint: + return FlightWaypoint( + FlightWaypointType.TARGET_GROUP_LOC, + self.package.target.position.x, + self.package.target.position.y, + meters(0), + ) + + @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( + FlightWaypointType.SPLIT, split.x, split.y, altitude + ) + + # Cheat in a FlightWaypoint for the refuel point. + refuel: Point = self.package.waypoints.refuel + refuel_waypoint: FlightWaypoint = FlightWaypoint( + FlightWaypointType.REFUEL, refuel.x, refuel.y, 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 @@ -1043,11 +1114,24 @@ class FlightPlanBuilder: elif task == FlightType.TRANSPORT: return self.generate_transport(flight) elif task == FlightType.REFUELING: - return self.generate_refueling_racetrack(flight) + 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 @@ -1067,6 +1151,12 @@ class FlightPlanBuilder: 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? @@ -1074,6 +1164,7 @@ class FlightPlanBuilder: WaypointBuilder.perturb(join_point), ingress_point, WaypointBuilder.perturb(join_point), + refuel_point, ) def generate_strike(self, flight: Flight) -> StrikeFlightPlan: @@ -1241,7 +1332,7 @@ class FlightPlanBuilder: ) def generate_sweep(self, flight: Flight) -> SweepFlightPlan: - """Generate a BARCAP flight at a given location. + """Generate a FighterSweep flight at a given location. Args: flight: The flight to generate the flight plan for. @@ -1260,6 +1351,11 @@ class FlightPlanBuilder: 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, @@ -1275,6 +1371,7 @@ class FlightPlanBuilder: ), sweep_start=start, sweep_end=end, + refuel=refuel, land=builder.land(flight.arrival), divert=builder.divert(flight.divert), bullseye=builder.bullseye(), @@ -1482,6 +1579,12 @@ class FlightPlanBuilder: 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, @@ -1498,6 +1601,7 @@ class FlightPlanBuilder: 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(), @@ -1611,6 +1715,9 @@ class FlightPlanBuilder: 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, @@ -1625,6 +1732,7 @@ class FlightPlanBuilder: ingress=ingress, targets=[target], split=split, + refuel=refuel, nav_from=builder.nav_path( split.position, flight.arrival.position, self.doctrine.ingress_altitude ), @@ -1703,6 +1811,11 @@ class FlightPlanBuilder: ) 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) @@ -1725,11 +1838,10 @@ class FlightPlanBuilder: orbit_heading.degrees, orbit_distance.meters ) - racetrack_half_distance = Distance.from_nautical_miles(20).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 ) @@ -1764,7 +1876,74 @@ class FlightPlanBuilder: land=builder.land(flight.arrival), divert=builder.divert(flight.divert), bullseye=builder.bullseye(), - patrol_duration=timedelta(hours=1), + 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. @@ -1843,6 +2022,9 @@ class FlightPlanBuilder: 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, @@ -1859,6 +2041,7 @@ class FlightPlanBuilder: ), targets=target_waypoints, split=split, + refuel=refuel, nav_from=builder.nav_path( split.position, flight.arrival.position, self.doctrine.ingress_altitude ), diff --git a/gen/flights/waypointbuilder.py b/gen/flights/waypointbuilder.py index 04869730..cdee7676 100644 --- a/gen/flights/waypointbuilder.py +++ b/gen/flights/waypointbuilder.py @@ -199,6 +199,20 @@ class WaypointBuilder: waypoint.name = "JOIN" return waypoint + def refuel(self, position: Point) -> FlightWaypoint: + waypoint = FlightWaypoint( + FlightWaypointType.REFUEL, + position.x, + position.y, + meters(80) if self.is_helo else self.doctrine.ingress_altitude, + ) + if self.is_helo: + waypoint.alt_type = "RADIO" + waypoint.pretty_name = "Refuel" + waypoint.description = "Refuel from tanker" + waypoint.name = "REFUEL" + return waypoint + def split(self, position: Point) -> FlightWaypoint: waypoint = FlightWaypoint( FlightWaypointType.SPLIT,