Clean up flight plan code.

Split the oversized file into one per plan type. This also moves the
layout responsibility out of the oversized FlightPlanBuilder and into
each flight plan type file.
This commit is contained in:
Dan Albert 2022-03-09 01:55:29 -08:00
parent c5fd3df235
commit fa8c0d9660
49 changed files with 2732 additions and 2249 deletions

View File

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

File diff suppressed because it is too large Load Diff

View File

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

144
game/ato/flightplans/cas.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,5 @@
from __future__ import annotations
class PlanningError(RuntimeError):
"""Raised when the flight planner was unable to create a flight plan."""

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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