diff --git a/game/ato/flight.py b/game/ato/flight.py index 71f77534..51417949 100644 --- a/game/ato/flight.py +++ b/game/ato/flight.py @@ -194,8 +194,9 @@ class Flight(SidcDescribable): def abort(self) -> None: from .flightplans.rtb import RtbFlightPlan - layout = RtbFlightPlan.builder_type()(self, self.coalition.game.theater).build() - self.flight_plan = RtbFlightPlan(self, layout) + self.flight_plan = RtbFlightPlan.builder_type()( + self, self.coalition.game.theater + ).build() self.set_state( Navigating( diff --git a/game/ato/flightplans/aewc.py b/game/ato/flightplans/aewc.py index 3f78d680..6da848e5 100644 --- a/game/ato/flightplans/aewc.py +++ b/game/ato/flightplans/aewc.py @@ -9,8 +9,31 @@ from game.ato.flightplans.waypointbuilder import WaypointBuilder from game.utils import Distance, Heading, Speed, feet, knots, meters, nautical_miles -class Builder(IBuilder): - def build(self) -> PatrollingLayout: +class AewcFlightPlan(PatrollingFlightPlan[PatrollingLayout]): + @property + def patrol_duration(self) -> timedelta: + return timedelta(hours=4) + + @property + def patrol_speed(self) -> Speed: + altitude = self.layout.patrol_start.alt + if self.flight.unit_type.preferred_patrol_speed(altitude) is not None: + return self.flight.unit_type.preferred_patrol_speed(altitude) + return knots(390) + + @property + def engagement_distance(self) -> Distance: + # 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. + return meters(0) + + @staticmethod + def builder_type() -> Type[Builder]: + return Builder + + +class Builder(IBuilder[AewcFlightPlan, PatrollingLayout]): + def layout(self) -> PatrollingLayout: racetrack_half_distance = nautical_miles(30).meters location = self.package.target @@ -67,25 +90,5 @@ class Builder(IBuilder): bullseye=builder.bullseye(), ) - -class AewcFlightPlan(PatrollingFlightPlan[PatrollingLayout]): - @property - def patrol_duration(self) -> timedelta: - return timedelta(hours=4) - - @property - def patrol_speed(self) -> Speed: - altitude = self.layout.patrol_start.alt - if self.flight.unit_type.preferred_patrol_speed(altitude) is not None: - return self.flight.unit_type.preferred_patrol_speed(altitude) - return knots(390) - - @property - def engagement_distance(self) -> Distance: - # 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. - return meters(0) - - @staticmethod - def builder_type() -> Type[IBuilder]: - return Builder + def build(self) -> AewcFlightPlan: + return AewcFlightPlan(self.flight, self.layout()) diff --git a/game/ato/flightplans/airassault.py b/game/ato/flightplans/airassault.py index 7b7c9737..9711ad4d 100644 --- a/game/ato/flightplans/airassault.py +++ b/game/ato/flightplans/airassault.py @@ -2,7 +2,8 @@ from __future__ import annotations from dataclasses import dataclass from datetime import timedelta -from typing import TYPE_CHECKING, Iterator, Type +from typing import Iterator, TYPE_CHECKING, Type + from game.ato.flightplans.airlift import AirliftLayout from game.ato.flightplans.standard import StandardFlightPlan from game.theater.controlpoint import ControlPointType @@ -16,8 +17,57 @@ if TYPE_CHECKING: from ..flightwaypoint import FlightWaypoint -class Builder(IBuilder): - def build(self) -> AirAssaultLayout: +@dataclass(frozen=True) +class AirAssaultLayout(AirliftLayout): + target: FlightWaypoint + + 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 self.target + yield from self.nav_to_home + yield self.arrival + if self.divert is not None: + yield self.divert + yield self.bullseye + + +class AirAssaultFlightPlan(StandardFlightPlan[AirAssaultLayout]): + def __init__(self, flight: Flight, layout: AirAssaultLayout) -> None: + super().__init__(flight, layout) + + @staticmethod + def builder_type() -> Type[Builder]: + return Builder + + @property + def tot_waypoint(self) -> FlightWaypoint | None: + return self.layout.drop_off + + def tot_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None: + if waypoint == self.tot_waypoint: + return self.tot + return None + + def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None: + return None + + @property + def engagement_distance(self) -> Distance: + # The radius of the WaypointZone created at the target location + return meters(2500) + + @property + def mission_departure_time(self) -> timedelta: + return self.package.time_over_target + + +class Builder(IBuilder[AirAssaultFlightPlan, AirAssaultLayout]): + def layout(self) -> AirAssaultLayout: altitude = feet(1500) if self.flight.is_helo else self.doctrine.ingress_altitude altitude_is_agl = self.flight.is_helo @@ -78,51 +128,5 @@ class Builder(IBuilder): bullseye=builder.bullseye(), ) - -@dataclass(frozen=True) -class AirAssaultLayout(AirliftLayout): - target: FlightWaypoint - - 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 self.target - yield from self.nav_to_home - yield self.arrival - if self.divert is not None: - yield self.divert - yield self.bullseye - - -class AirAssaultFlightPlan(StandardFlightPlan[AirAssaultLayout]): - def __init__(self, flight: Flight, layout: AirAssaultLayout) -> None: - super().__init__(flight, layout) - - @staticmethod - def builder_type() -> Type[Builder]: - return Builder - - @property - def tot_waypoint(self) -> FlightWaypoint | None: - return self.layout.drop_off - - def tot_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None: - if waypoint == self.tot_waypoint: - return self.tot - return None - - def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None: - return None - - @property - def engagement_distance(self) -> Distance: - # The radius of the WaypointZone created at the target location - return meters(2500) - - @property - def mission_departure_time(self) -> timedelta: - return self.package.time_over_target + def build(self) -> AirAssaultFlightPlan: + return AirAssaultFlightPlan(self.flight, self.layout()) diff --git a/game/ato/flightplans/airlift.py b/game/ato/flightplans/airlift.py index a0513da0..eac02621 100644 --- a/game/ato/flightplans/airlift.py +++ b/game/ato/flightplans/airlift.py @@ -4,8 +4,8 @@ from collections.abc import Iterator from dataclasses import dataclass from datetime import timedelta from typing import TYPE_CHECKING, Type -from game.theater.missiontarget import MissionTarget +from game.theater.missiontarget import MissionTarget from game.utils import feet from .ibuilder import IBuilder from .planningerror import PlanningError @@ -17,8 +17,58 @@ if TYPE_CHECKING: from ..flightwaypoint import FlightWaypoint -class Builder(IBuilder): - def build(self) -> AirliftLayout: +@dataclass(frozen=True) +class AirliftLayout(StandardLayout): + nav_to_pickup: list[FlightWaypoint] + pickup: FlightWaypoint | None + nav_to_drop_off: list[FlightWaypoint] + drop_off: FlightWaypoint + stopover: FlightWaypoint | None + nav_to_home: list[FlightWaypoint] + + def iter_waypoints(self) -> Iterator[FlightWaypoint]: + yield self.departure + yield from self.nav_to_pickup + if self.pickup is not None: + yield self.pickup + yield from self.nav_to_drop_off + yield self.drop_off + if self.stopover is not None: + yield self.stopover + yield from self.nav_to_home + yield self.arrival + if self.divert is not None: + yield self.divert + yield self.bullseye + + +class AirliftFlightPlan(StandardFlightPlan[AirliftLayout]): + def __init__(self, flight: Flight, layout: AirliftLayout) -> None: + super().__init__(flight, layout) + + @staticmethod + def builder_type() -> Type[Builder]: + return Builder + + @property + def tot_waypoint(self) -> FlightWaypoint | None: + return self.layout.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 + + +class Builder(IBuilder[AirliftFlightPlan, AirliftLayout]): + def layout(self) -> AirliftLayout: cargo = self.flight.cargo if cargo is None: raise PlanningError( @@ -97,52 +147,5 @@ class Builder(IBuilder): bullseye=builder.bullseye(), ) - -@dataclass(frozen=True) -class AirliftLayout(StandardLayout): - nav_to_pickup: list[FlightWaypoint] - pickup: FlightWaypoint | None - nav_to_drop_off: list[FlightWaypoint] - drop_off: FlightWaypoint - stopover: FlightWaypoint | None - nav_to_home: list[FlightWaypoint] - - def iter_waypoints(self) -> Iterator[FlightWaypoint]: - yield self.departure - yield from self.nav_to_pickup - if self.pickup is not None: - yield self.pickup - yield from self.nav_to_drop_off - yield self.drop_off - if self.stopover is not None: - yield self.stopover - yield from self.nav_to_home - yield self.arrival - if self.divert is not None: - yield self.divert - yield self.bullseye - - -class AirliftFlightPlan(StandardFlightPlan[AirliftLayout]): - def __init__(self, flight: Flight, layout: AirliftLayout) -> None: - super().__init__(flight, layout) - - @staticmethod - def builder_type() -> Type[Builder]: - return Builder - - @property - def tot_waypoint(self) -> FlightWaypoint | None: - return self.layout.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 + def build(self) -> AirliftFlightPlan: + return AirliftFlightPlan(self.flight, self.layout()) diff --git a/game/ato/flightplans/antiship.py b/game/ato/flightplans/antiship.py index 4326ecc2..89ec1f85 100644 --- a/game/ato/flightplans/antiship.py +++ b/game/ato/flightplans/antiship.py @@ -20,8 +20,8 @@ class AntiShipFlightPlan(FormationAttackFlightPlan): return Builder -class Builder(FormationAttackBuilder): - def build(self) -> FormationAttackLayout: +class Builder(FormationAttackBuilder[AntiShipFlightPlan, FormationAttackLayout]): + def layout(self) -> FormationAttackLayout: location = self.package.target from game.transfers import CargoShip @@ -40,3 +40,6 @@ class Builder(FormationAttackBuilder): @staticmethod def anti_ship_targets_for_tgo(tgo: NavalGroundObject) -> list[StrikeTarget]: return [StrikeTarget(f"{g.group_name} at {tgo.name}", g) for g in tgo.groups] + + def build(self) -> AntiShipFlightPlan: + return AntiShipFlightPlan(self.flight, self.layout()) diff --git a/game/ato/flightplans/bai.py b/game/ato/flightplans/bai.py index 837e7352..e64e27ac 100644 --- a/game/ato/flightplans/bai.py +++ b/game/ato/flightplans/bai.py @@ -19,8 +19,8 @@ class BaiFlightPlan(FormationAttackFlightPlan): return Builder -class Builder(FormationAttackBuilder): - def build(self) -> FormationAttackLayout: +class Builder(FormationAttackBuilder[BaiFlightPlan, FormationAttackLayout]): + def layout(self) -> FormationAttackLayout: location = self.package.target from game.transfers import Convoy @@ -38,3 +38,6 @@ class Builder(FormationAttackBuilder): raise InvalidObjectiveLocation(self.flight.flight_type, location) return self._build(FlightWaypointType.INGRESS_BAI, targets) + + def build(self) -> BaiFlightPlan: + return BaiFlightPlan(self.flight, self.layout()) diff --git a/game/ato/flightplans/barcap.py b/game/ato/flightplans/barcap.py index b2f4abe3..615dcf48 100644 --- a/game/ato/flightplans/barcap.py +++ b/game/ato/flightplans/barcap.py @@ -12,8 +12,28 @@ from .patrolling import PatrollingFlightPlan, PatrollingLayout from .waypointbuilder import WaypointBuilder -class Builder(CapBuilder): - def build(self) -> PatrollingLayout: +class BarCapFlightPlan(PatrollingFlightPlan[PatrollingLayout]): + @staticmethod + def builder_type() -> Type[Builder]: + return Builder + + @property + def patrol_duration(self) -> timedelta: + return self.flight.coalition.doctrine.cap_duration + + @property + def patrol_speed(self) -> Speed: + return self.flight.unit_type.preferred_patrol_speed( + self.layout.patrol_start.alt + ) + + @property + def engagement_distance(self) -> Distance: + return self.flight.coalition.doctrine.cap_engagement_range + + +class Builder(CapBuilder[BarCapFlightPlan, PatrollingLayout]): + def layout(self) -> PatrollingLayout: location = self.package.target if isinstance(location, FrontLine): @@ -46,22 +66,5 @@ class Builder(CapBuilder): bullseye=builder.bullseye(), ) - -class BarCapFlightPlan(PatrollingFlightPlan[PatrollingLayout]): - @staticmethod - def builder_type() -> Type[Builder]: - return Builder - - @property - def patrol_duration(self) -> timedelta: - return self.flight.coalition.doctrine.cap_duration - - @property - def patrol_speed(self) -> Speed: - return self.flight.unit_type.preferred_patrol_speed( - self.layout.patrol_start.alt - ) - - @property - def engagement_distance(self) -> Distance: - return self.flight.coalition.doctrine.cap_engagement_range + def build(self) -> BarCapFlightPlan: + return BarCapFlightPlan(self.flight, self.layout()) diff --git a/game/ato/flightplans/capbuilder.py b/game/ato/flightplans/capbuilder.py index 75fe0a64..0d8ad965 100644 --- a/game/ato/flightplans/capbuilder.py +++ b/game/ato/flightplans/capbuilder.py @@ -2,12 +2,14 @@ from __future__ import annotations import random from abc import ABC -from typing import TYPE_CHECKING +from typing import Any, TYPE_CHECKING, TypeVar from dcs import Point from shapely.geometry import Point as ShapelyPoint from game.utils import Heading, meters, nautical_miles +from .flightplan import FlightPlan +from .patrolling import PatrollingLayout from ..closestairfields import ObjectiveDistanceCache from ..flightplans.ibuilder import IBuilder from ..flightplans.planningerror import PlanningError @@ -15,8 +17,11 @@ from ..flightplans.planningerror import PlanningError if TYPE_CHECKING: from game.theater import MissionTarget +FlightPlanT = TypeVar("FlightPlanT", bound=FlightPlan[Any]) +LayoutT = TypeVar("LayoutT", bound=PatrollingLayout) -class CapBuilder(IBuilder, ABC): + +class CapBuilder(IBuilder[FlightPlanT, LayoutT], ABC): def cap_racetrack_for_objective( self, location: MissionTarget, barcap: bool ) -> tuple[Point, Point]: diff --git a/game/ato/flightplans/cas.py b/game/ato/flightplans/cas.py index 9c24219f..53d2f780 100644 --- a/game/ato/flightplans/cas.py +++ b/game/ato/flightplans/cas.py @@ -17,8 +17,58 @@ if TYPE_CHECKING: from ..flightwaypoint import FlightWaypoint -class Builder(IBuilder): - def build(self) -> CasLayout: +@dataclass(frozen=True) +class CasLayout(PatrollingLayout): + target: FlightWaypoint + + 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 + + +class CasFlightPlan(PatrollingFlightPlan[CasLayout]): + @staticmethod + def builder_type() -> Type[Builder]: + return Builder + + @property + def patrol_duration(self) -> timedelta: + return self.flight.coalition.doctrine.cas_duration + + @property + def patrol_speed(self) -> Speed: + # 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 + return kph(0) + + @property + def engagement_distance(self) -> Distance: + from game.missiongenerator.frontlineconflictdescription import FRONTLINE_LENGTH + + return meters(FRONTLINE_LENGTH) / 2 + + @property + def combat_speed_waypoints(self) -> set[FlightWaypoint]: + return {self.layout.patrol_start, self.layout.target, self.layout.patrol_end} + + def request_escort_at(self) -> FlightWaypoint | None: + return self.layout.patrol_start + + def dismiss_escort_at(self) -> FlightWaypoint | None: + return self.layout.patrol_end + + +class Builder(IBuilder[CasFlightPlan, CasLayout]): + def layout(self) -> CasLayout: location = self.package.target if not isinstance(location, FrontLine): @@ -71,52 +121,5 @@ class Builder(IBuilder): bullseye=builder.bullseye(), ) - -@dataclass(frozen=True) -class CasLayout(PatrollingLayout): - target: FlightWaypoint - - 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 - - -class CasFlightPlan(PatrollingFlightPlan[CasLayout]): - @staticmethod - def builder_type() -> Type[Builder]: - return Builder - - @property - def patrol_duration(self) -> timedelta: - return self.flight.coalition.doctrine.cas_duration - - @property - def patrol_speed(self) -> Speed: - # 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 - return kph(0) - - @property - def engagement_distance(self) -> Distance: - from game.missiongenerator.frontlineconflictdescription import FRONTLINE_LENGTH - - return meters(FRONTLINE_LENGTH) / 2 - - @property - def combat_speed_waypoints(self) -> set[FlightWaypoint]: - return {self.layout.patrol_start, self.layout.target, self.layout.patrol_end} - - def request_escort_at(self) -> FlightWaypoint | None: - return self.layout.patrol_start - - def dismiss_escort_at(self) -> FlightWaypoint | None: - return self.layout.patrol_end + def build(self) -> CasFlightPlan: + return CasFlightPlan(self.flight, self.layout()) diff --git a/game/ato/flightplans/custom.py b/game/ato/flightplans/custom.py index 652a9cae..ec18f2eb 100644 --- a/game/ato/flightplans/custom.py +++ b/game/ato/flightplans/custom.py @@ -13,11 +13,6 @@ if TYPE_CHECKING: from ..flightwaypoint import FlightWaypoint -class Builder(IBuilder): - def build(self) -> CustomLayout: - return CustomLayout([]) - - @dataclass(frozen=True) class CustomLayout(Layout): custom_waypoints: list[FlightWaypoint] @@ -55,3 +50,11 @@ class CustomFlightPlan(FlightPlan[CustomLayout]): @property def mission_departure_time(self) -> timedelta: return self.package.time_over_target + + +class Builder(IBuilder[CustomFlightPlan, CustomLayout]): + def layout(self) -> CustomLayout: + return CustomLayout([]) + + def build(self) -> CustomFlightPlan: + return CustomFlightPlan(self.flight, self.layout()) diff --git a/game/ato/flightplans/dead.py b/game/ato/flightplans/dead.py index b3a38a54..6827130d 100644 --- a/game/ato/flightplans/dead.py +++ b/game/ato/flightplans/dead.py @@ -22,8 +22,8 @@ class DeadFlightPlan(FormationAttackFlightPlan): return Builder -class Builder(FormationAttackBuilder): - def build(self) -> FormationAttackLayout: +class Builder(FormationAttackBuilder[DeadFlightPlan, FormationAttackLayout]): + def layout(self) -> FormationAttackLayout: location = self.package.target is_ewr = isinstance(location, EwrGroundObject) @@ -36,3 +36,6 @@ class Builder(FormationAttackBuilder): raise InvalidObjectiveLocation(self.flight.flight_type, location) return self._build(FlightWaypointType.INGRESS_DEAD) + + def build(self) -> DeadFlightPlan: + return DeadFlightPlan(self.flight, self.layout()) diff --git a/game/ato/flightplans/escort.py b/game/ato/flightplans/escort.py index a1008ce3..f07d3e63 100644 --- a/game/ato/flightplans/escort.py +++ b/game/ato/flightplans/escort.py @@ -16,8 +16,8 @@ class EscortFlightPlan(FormationAttackFlightPlan): return Builder -class Builder(FormationAttackBuilder): - def build(self) -> FormationAttackLayout: +class Builder(FormationAttackBuilder[EscortFlightPlan, FormationAttackLayout]): + def layout(self) -> FormationAttackLayout: assert self.package.waypoints is not None builder = WaypointBuilder(self.flight, self.coalition) @@ -51,3 +51,6 @@ class Builder(FormationAttackBuilder): divert=builder.divert(self.flight.divert), bullseye=builder.bullseye(), ) + + def build(self) -> EscortFlightPlan: + return EscortFlightPlan(self.flight, self.layout()) diff --git a/game/ato/flightplans/ferry.py b/game/ato/flightplans/ferry.py index 5c27cd49..c6d6e7cc 100644 --- a/game/ato/flightplans/ferry.py +++ b/game/ato/flightplans/ferry.py @@ -15,36 +15,6 @@ if TYPE_CHECKING: from ..flightwaypoint import FlightWaypoint -class Builder(IBuilder): - def build(self) -> FerryLayout: - 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 FerryLayout( - 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(), - ) - - @dataclass(frozen=True) class FerryLayout(StandardLayout): nav_to_destination: list[FlightWaypoint] @@ -78,3 +48,36 @@ class FerryFlightPlan(StandardFlightPlan[FerryLayout]): @property def mission_departure_time(self) -> timedelta: return self.package.time_over_target + + +class Builder(IBuilder[FerryFlightPlan, FerryLayout]): + def layout(self) -> FerryLayout: + 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 FerryLayout( + 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(), + ) + + def build(self) -> FerryFlightPlan: + return FerryFlightPlan(self.flight, self.layout()) diff --git a/game/ato/flightplans/flightplan.py b/game/ato/flightplans/flightplan.py index 3021b658..1496da10 100644 --- a/game/ato/flightplans/flightplan.py +++ b/game/ato/flightplans/flightplan.py @@ -8,15 +8,14 @@ generating the waypoints for the mission. from __future__ import annotations import math -from abc import ABC, abstractmethod +from abc import ABC from collections.abc import Iterator from datetime import timedelta from functools import cached_property -from typing import Any, Generic, TYPE_CHECKING, Type, TypeGuard, TypeVar +from typing import Any, Generic, TYPE_CHECKING, TypeGuard, TypeVar 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 @@ -64,11 +63,6 @@ class FlightPlan(ABC, Generic[LayoutT]): 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.""" diff --git a/game/ato/flightplans/flightplanbuilder.py b/game/ato/flightplans/flightplanbuilder.py index 38f68213..4dd45a73 100644 --- a/game/ato/flightplans/flightplanbuilder.py +++ b/game/ato/flightplans/flightplanbuilder.py @@ -14,6 +14,7 @@ from .dead import DeadFlightPlan from .escort import EscortFlightPlan from .ferry import FerryFlightPlan from .flightplan import FlightPlan +from .ibuilder import IBuilder from .ocaaircraft import OcaAircraftFlightPlan from .ocarunway import OcaRunwayFlightPlan from .packagerefueling import PackageRefuelingFlightPlan @@ -72,42 +73,39 @@ class FlightPlanBuilder: f"{flight.departure} to {flight.package.target}" ) from ex - def plan_type(self, flight: Flight) -> Type[FlightPlan[Any]]: - plan_type: Type[FlightPlan[Any]] + def builder_type(self, flight: Flight) -> Type[IBuilder[Any, Any]]: if flight.flight_type is FlightType.REFUELING: if self.package.target.is_friendly(self.is_player) or isinstance( self.package.target, FrontLine ): - return TheaterRefuelingFlightPlan - return PackageRefuelingFlightPlan + return TheaterRefuelingFlightPlan.builder_type() + return PackageRefuelingFlightPlan.builder_type() - plan_dict: dict[FlightType, Type[FlightPlan[Any]]] = { - 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, - FlightType.AIR_ASSAULT: AirAssaultFlightPlan, + builder_dict: dict[FlightType, Type[IBuilder[Any, Any]]] = { + FlightType.ANTISHIP: AntiShipFlightPlan.builder_type(), + FlightType.BAI: BaiFlightPlan.builder_type(), + FlightType.BARCAP: BarCapFlightPlan.builder_type(), + FlightType.CAS: CasFlightPlan.builder_type(), + FlightType.DEAD: DeadFlightPlan.builder_type(), + FlightType.ESCORT: EscortFlightPlan.builder_type(), + FlightType.OCA_AIRCRAFT: OcaAircraftFlightPlan.builder_type(), + FlightType.OCA_RUNWAY: OcaRunwayFlightPlan.builder_type(), + FlightType.SEAD: SeadFlightPlan.builder_type(), + FlightType.SEAD_ESCORT: EscortFlightPlan.builder_type(), + FlightType.STRIKE: StrikeFlightPlan.builder_type(), + FlightType.SWEEP: SweepFlightPlan.builder_type(), + FlightType.TARCAP: TarCapFlightPlan.builder_type(), + FlightType.AEWC: AewcFlightPlan.builder_type(), + FlightType.TRANSPORT: AirliftFlightPlan.builder_type(), + FlightType.FERRY: FerryFlightPlan.builder_type(), + FlightType.AIR_ASSAULT: AirAssaultFlightPlan.builder_type(), } try: - return plan_dict[flight.flight_type] + return builder_dict[flight.flight_type] except KeyError as ex: raise PlanningError( f"{flight.flight_type} flight plan generation not implemented" ) from ex def generate_flight_plan(self, flight: Flight) -> FlightPlan[Any]: - plan_type = self.plan_type(flight) - layout = plan_type.builder_type()(flight, self.theater).build() - return plan_type(flight, layout) + return self.builder_type(flight)(flight, self.theater).build() diff --git a/game/ato/flightplans/formationattack.py b/game/ato/flightplans/formationattack.py index 20d1cdd4..9a0c6ed3 100644 --- a/game/ato/flightplans/formationattack.py +++ b/game/ato/flightplans/formationattack.py @@ -11,6 +11,7 @@ from dcs import Point from game.flightplan import HoldZoneGeometry from game.theater import MissionTarget from game.utils import Speed, meters +from .flightplan import FlightPlan from .formation import FormationFlightPlan, FormationLayout from .ibuilder import IBuilder from .planningerror import PlanningError @@ -151,10 +152,11 @@ class FormationAttackLayout(FormationLayout): yield self.bullseye +FlightPlanT = TypeVar("FlightPlanT", bound=FlightPlan[FormationAttackLayout]) LayoutT = TypeVar("LayoutT", bound=FormationAttackLayout) -class FormationAttackBuilder(IBuilder, ABC): +class FormationAttackBuilder(IBuilder[FlightPlanT, LayoutT], ABC): def _build( self, ingress_type: FlightWaypointType, diff --git a/game/ato/flightplans/ibuilder.py b/game/ato/flightplans/ibuilder.py index 3ef5de2f..801f2ad6 100644 --- a/game/ato/flightplans/ibuilder.py +++ b/game/ato/flightplans/ibuilder.py @@ -1,7 +1,9 @@ from __future__ import annotations from abc import ABC, abstractmethod -from typing import TYPE_CHECKING +from typing import Any, Generic, TYPE_CHECKING, TypeVar + +from .flightplan import FlightPlan, Layout if TYPE_CHECKING: from game.coalition import Coalition @@ -10,16 +12,23 @@ if TYPE_CHECKING: from game.threatzones import ThreatZones from ..flight import Flight from ..package import Package - from .flightplan import Layout -class IBuilder(ABC): +FlightPlanT = TypeVar("FlightPlanT", bound=FlightPlan[Any]) +LayoutT = TypeVar("LayoutT", bound=Layout) + + +class IBuilder(ABC, Generic[FlightPlanT, LayoutT]): def __init__(self, flight: Flight, theater: ConflictTheater) -> None: self.flight = flight self.theater = theater @abstractmethod - def build(self) -> Layout: + def layout(self) -> LayoutT: + ... + + @abstractmethod + def build(self) -> FlightPlanT: ... @property diff --git a/game/ato/flightplans/ocaaircraft.py b/game/ato/flightplans/ocaaircraft.py index f8de4648..5b8ba6da 100644 --- a/game/ato/flightplans/ocaaircraft.py +++ b/game/ato/flightplans/ocaaircraft.py @@ -19,8 +19,8 @@ class OcaAircraftFlightPlan(FormationAttackFlightPlan): return Builder -class Builder(FormationAttackBuilder): - def build(self) -> FormationAttackLayout: +class Builder(FormationAttackBuilder[OcaAircraftFlightPlan, FormationAttackLayout]): + def layout(self) -> FormationAttackLayout: location = self.package.target if not isinstance(location, Airfield): @@ -31,3 +31,6 @@ class Builder(FormationAttackBuilder): raise InvalidObjectiveLocation(self.flight.flight_type, location) return self._build(FlightWaypointType.INGRESS_OCA_AIRCRAFT) + + def build(self) -> OcaAircraftFlightPlan: + return OcaAircraftFlightPlan(self.flight, self.layout()) diff --git a/game/ato/flightplans/ocarunway.py b/game/ato/flightplans/ocarunway.py index 97d49cef..fd7b3bfd 100644 --- a/game/ato/flightplans/ocarunway.py +++ b/game/ato/flightplans/ocarunway.py @@ -19,8 +19,8 @@ class OcaRunwayFlightPlan(FormationAttackFlightPlan): return Builder -class Builder(FormationAttackBuilder): - def build(self) -> FormationAttackLayout: +class Builder(FormationAttackBuilder[OcaRunwayFlightPlan, FormationAttackLayout]): + def layout(self) -> FormationAttackLayout: location = self.package.target if not isinstance(location, Airfield): @@ -31,3 +31,6 @@ class Builder(FormationAttackBuilder): raise InvalidObjectiveLocation(self.flight.flight_type, location) return self._build(FlightWaypointType.INGRESS_OCA_RUNWAY) + + def build(self) -> OcaRunwayFlightPlan: + return OcaRunwayFlightPlan(self.flight, self.layout()) diff --git a/game/ato/flightplans/packagerefueling.py b/game/ato/flightplans/packagerefueling.py index d63796f8..e59e130c 100644 --- a/game/ato/flightplans/packagerefueling.py +++ b/game/ato/flightplans/packagerefueling.py @@ -6,65 +6,15 @@ from typing import Type from dcs import Point from game.utils import Distance, Heading, feet, meters +from .ibuilder import IBuilder from .patrolling import PatrollingLayout -from .theaterrefueling import ( - Builder as TheaterRefuelingBuilder, - TheaterRefuelingFlightPlan, -) +from .refuelingflightplan import RefuelingFlightPlan from .waypointbuilder import WaypointBuilder from ..flightwaypoint import FlightWaypoint from ..flightwaypointtype import FlightWaypointType -class Builder(TheaterRefuelingBuilder): - def build(self) -> PatrollingLayout: - package_waypoints = self.package.waypoints - assert package_waypoints is not None - - racetrack_half_distance = Distance.from_nautical_miles(20).meters - - 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) - - racetrack = builder.race_track(racetrack_start, racetrack_end, altitude) - - return PatrollingLayout( - 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(), - ) - - -class PackageRefuelingFlightPlan(TheaterRefuelingFlightPlan): +class PackageRefuelingFlightPlan(RefuelingFlightPlan): @staticmethod def builder_type() -> Type[Builder]: return Builder @@ -122,3 +72,54 @@ class PackageRefuelingFlightPlan(TheaterRefuelingFlightPlan): + delay_split_to_refuel - timedelta(minutes=1.5) ) + + +class Builder(IBuilder[PackageRefuelingFlightPlan, PatrollingLayout]): + def layout(self) -> PatrollingLayout: + package_waypoints = self.package.waypoints + assert package_waypoints is not None + + racetrack_half_distance = Distance.from_nautical_miles(20).meters + + 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) + + racetrack = builder.race_track(racetrack_start, racetrack_end, altitude) + + return PatrollingLayout( + 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(), + ) + + def build(self) -> PackageRefuelingFlightPlan: + return PackageRefuelingFlightPlan(self.flight, self.layout()) diff --git a/game/ato/flightplans/refuelingflightplan.py b/game/ato/flightplans/refuelingflightplan.py new file mode 100644 index 00000000..2fb00d5b --- /dev/null +++ b/game/ato/flightplans/refuelingflightplan.py @@ -0,0 +1,20 @@ +from abc import ABC + +from game.utils import Distance, Speed, knots, meters +from .patrolling import PatrollingFlightPlan, PatrollingLayout + + +class RefuelingFlightPlan(PatrollingFlightPlan[PatrollingLayout], ABC): + @property + def patrol_speed(self) -> Speed: + # TODO: Could use self.flight.unit_type.preferred_patrol_speed(altitude). + if self.flight.unit_type.patrol_speed is not None: + return self.flight.unit_type.patrol_speed + # ~280 knots IAS at 21000. + return knots(400) + + @property + def engagement_distance(self) -> Distance: + # 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. + return meters(0) diff --git a/game/ato/flightplans/rtb.py b/game/ato/flightplans/rtb.py index b8806c53..7555179f 100644 --- a/game/ato/flightplans/rtb.py +++ b/game/ato/flightplans/rtb.py @@ -15,42 +15,6 @@ if TYPE_CHECKING: from ..flightwaypoint import FlightWaypoint -class Builder(IBuilder): - def build(self) -> RtbLayout: - 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 RtbLayout( - 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(), - ) - - @dataclass(frozen=True) class RtbLayout(StandardLayout): abort_location: FlightWaypoint @@ -88,3 +52,42 @@ class RtbFlightPlan(StandardFlightPlan[RtbLayout]): @property def mission_departure_time(self) -> timedelta: return timedelta() + + +class Builder(IBuilder[RtbFlightPlan, RtbLayout]): + def layout(self) -> RtbLayout: + 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 RtbLayout( + 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(), + ) + + def build(self) -> RtbFlightPlan: + return RtbFlightPlan(self.flight, self.layout()) diff --git a/game/ato/flightplans/sead.py b/game/ato/flightplans/sead.py index b82b59f4..8a273d6e 100644 --- a/game/ato/flightplans/sead.py +++ b/game/ato/flightplans/sead.py @@ -25,6 +25,9 @@ class SeadFlightPlan(FormationAttackFlightPlan): return timedelta(minutes=1) -class Builder(FormationAttackBuilder): - def build(self) -> FormationAttackLayout: +class Builder(FormationAttackBuilder[SeadFlightPlan, FormationAttackLayout]): + def layout(self) -> FormationAttackLayout: return self._build(FlightWaypointType.INGRESS_SEAD) + + def build(self) -> SeadFlightPlan: + return SeadFlightPlan(self.flight, self.layout()) diff --git a/game/ato/flightplans/strike.py b/game/ato/flightplans/strike.py index 145917d7..303c50b6 100644 --- a/game/ato/flightplans/strike.py +++ b/game/ato/flightplans/strike.py @@ -19,8 +19,8 @@ class StrikeFlightPlan(FormationAttackFlightPlan): return Builder -class Builder(FormationAttackBuilder): - def build(self) -> FormationAttackLayout: +class Builder(FormationAttackBuilder[StrikeFlightPlan, FormationAttackLayout]): + def layout(self) -> FormationAttackLayout: location = self.package.target if not isinstance(location, TheaterGroundObject): @@ -31,3 +31,6 @@ class Builder(FormationAttackBuilder): targets.append(StrikeTarget(f"{unit.type.id} #{idx}", unit)) return self._build(FlightWaypointType.INGRESS_STRIKE, targets) + + def build(self) -> StrikeFlightPlan: + return StrikeFlightPlan(self.flight, self.layout()) diff --git a/game/ato/flightplans/sweep.py b/game/ato/flightplans/sweep.py index 7c351968..5c4e8761 100644 --- a/game/ato/flightplans/sweep.py +++ b/game/ato/flightplans/sweep.py @@ -17,57 +17,6 @@ if TYPE_CHECKING: from ..flightwaypoint import FlightWaypoint -class Builder(IBuilder): - def build(self) -> SweepLayout: - 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 SweepLayout( - departure=builder.takeoff(self.flight.departure), - hold=hold, - 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() - - @dataclass(frozen=True) class SweepLayout(LoiterLayout): nav_to: list[FlightWaypoint] @@ -145,3 +94,57 @@ class SweepFlightPlan(LoiterFlightPlan): def mission_departure_time(self) -> timedelta: return self.sweep_end_time + + +class Builder(IBuilder[SweepFlightPlan, SweepLayout]): + def layout(self) -> SweepLayout: + 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 SweepLayout( + departure=builder.takeoff(self.flight.departure), + hold=hold, + 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() + + def build(self) -> SweepFlightPlan: + return SweepFlightPlan(self.flight, self.layout()) diff --git a/game/ato/flightplans/tarcap.py b/game/ato/flightplans/tarcap.py index 6ec4c1b5..1ae46967 100644 --- a/game/ato/flightplans/tarcap.py +++ b/game/ato/flightplans/tarcap.py @@ -15,44 +15,6 @@ if TYPE_CHECKING: from ..flightwaypoint import FlightWaypoint -class Builder(CapBuilder): - def build(self) -> TarCapLayout: - 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), - ) - - 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 TarCapLayout( - 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(), - ) - - @dataclass(frozen=True) class TarCapLayout(PatrollingLayout): refuel: FlightWaypoint | None @@ -124,3 +86,44 @@ class TarCapFlightPlan(PatrollingFlightPlan[TarCapLayout]): if end is not None: return end return super().patrol_end_time + + +class Builder(CapBuilder[TarCapFlightPlan, TarCapLayout]): + def layout(self) -> TarCapLayout: + 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), + ) + + 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 TarCapLayout( + 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(), + ) + + def build(self) -> TarCapFlightPlan: + return TarCapFlightPlan(self.flight, self.layout()) diff --git a/game/ato/flightplans/theaterrefueling.py b/game/ato/flightplans/theaterrefueling.py index f40d3cf5..08ed94eb 100644 --- a/game/ato/flightplans/theaterrefueling.py +++ b/game/ato/flightplans/theaterrefueling.py @@ -3,14 +3,25 @@ from __future__ import annotations from datetime import timedelta from typing import Type -from game.utils import Distance, Heading, Speed, feet, knots, meters, nautical_miles +from game.utils import Heading, feet, meters, nautical_miles from .ibuilder import IBuilder -from .patrolling import PatrollingFlightPlan, PatrollingLayout +from .patrolling import PatrollingLayout +from .refuelingflightplan import RefuelingFlightPlan from .waypointbuilder import WaypointBuilder -class Builder(IBuilder): - def build(self) -> PatrollingLayout: +class TheaterRefuelingFlightPlan(RefuelingFlightPlan): + @staticmethod + def builder_type() -> Type[Builder]: + return Builder + + @property + def patrol_duration(self) -> timedelta: + return timedelta(hours=1) + + +class Builder(IBuilder[TheaterRefuelingFlightPlan, PatrollingLayout]): + def layout(self) -> PatrollingLayout: racetrack_half_distance = nautical_miles(20).meters location = self.package.target @@ -68,26 +79,5 @@ class Builder(IBuilder): bullseye=builder.bullseye(), ) - -class TheaterRefuelingFlightPlan(PatrollingFlightPlan[PatrollingLayout]): - @staticmethod - def builder_type() -> Type[Builder]: - return Builder - - @property - def patrol_duration(self) -> timedelta: - return timedelta(hours=1) - - @property - def patrol_speed(self) -> Speed: - # TODO: Could use self.flight.unit_type.preferred_patrol_speed(altitude). - if self.flight.unit_type.patrol_speed is not None: - return self.flight.unit_type.patrol_speed - # ~280 knots IAS at 21000. - return knots(400) - - @property - def engagement_distance(self) -> Distance: - # 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. - return meters(0) + def build(self) -> TheaterRefuelingFlightPlan: + return TheaterRefuelingFlightPlan(self.flight, self.layout())