Ability to plan tanker recovery for strike like flights (#1729)

* Initial refueling definitions.

* Adding refuel definitions.

* Initial functionality changes

* Regenerate package when adding refueling flight.

* Recursively change package waypoint.

* Fix mypy errors.

* Regenerate flight plans when tanker is added to package.

* Give tanker better starting position on package recovery.

* Add TOT calculation for refueling waypoint.

* Timing changes to Strike split point and Refueling start time.

* Add correct waypoint builder for refuel in tarcap and sweep.  Remove restrict afterburner on refuel point.

* Always generate a refuel point for a package.

* Less arbitrary altitude in Refuel track start time calculation.

* Refueling waypoint no longer optional.

* Fix mypy gen error.

* Better discrimination of which tanker flight plan to make.

* Remove refuel tot calculations.

* Remove package regeneration on tanker flight addition.
This commit is contained in:
SnappyComebacks 2021-11-12 21:37:34 -07:00 committed by GitHub
parent 94f65d8f70
commit 532ac261ff
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 251 additions and 10 deletions

View File

@ -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

View File

@ -45,3 +45,4 @@ class FlightWaypointType(Enum):
PICKUP = 26
DROP_OFF = 27
BULLSEYE = 28
REFUEL = 29 # Should look for nearby tanker to refuel from.

View File

@ -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.

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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(

View File

@ -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:
"""

View File

@ -132,6 +132,7 @@ class TheaterGroundObject(MissionTarget, Generic[GroupT]):
yield from [
FlightType.STRIKE,
FlightType.BAI,
FlightType.REFUELING,
]
yield from super().mission_types(for_player)

View File

@ -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
),

View File

@ -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,