From 452105380432e8467553562c11ca7b16b256483c Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Wed, 10 Aug 2022 18:22:28 -0700 Subject: [PATCH] Move FlightPlan instantiation into the builder. I'm working on moving the builder to be owned by the Flight, which will simplify callers that need to create (or recreate) flight plans for a flight. --- game/ato/flight.py | 5 +- game/ato/flightplans/aewc.py | 51 +++++----- game/ato/flightplans/airassault.py | 106 +++++++++---------- game/ato/flightplans/airlift.py | 107 ++++++++++---------- game/ato/flightplans/antiship.py | 7 +- game/ato/flightplans/bai.py | 7 +- game/ato/flightplans/barcap.py | 45 ++++---- game/ato/flightplans/capbuilder.py | 9 +- game/ato/flightplans/cas.py | 105 +++++++++---------- game/ato/flightplans/custom.py | 13 ++- game/ato/flightplans/dead.py | 7 +- game/ato/flightplans/escort.py | 7 +- game/ato/flightplans/ferry.py | 63 ++++++------ game/ato/flightplans/flightplan.py | 10 +- game/ato/flightplans/flightplanbuilder.py | 50 +++++---- game/ato/flightplans/formationattack.py | 4 +- game/ato/flightplans/ibuilder.py | 17 +++- game/ato/flightplans/ocaaircraft.py | 7 +- game/ato/flightplans/ocarunway.py | 7 +- game/ato/flightplans/packagerefueling.py | 107 ++++++++++---------- game/ato/flightplans/refuelingflightplan.py | 20 ++++ game/ato/flightplans/rtb.py | 75 +++++++------- game/ato/flightplans/sead.py | 7 +- game/ato/flightplans/strike.py | 7 +- game/ato/flightplans/sweep.py | 105 +++++++++---------- game/ato/flightplans/tarcap.py | 79 ++++++++------- game/ato/flightplans/theaterrefueling.py | 44 ++++---- 27 files changed, 573 insertions(+), 498 deletions(-) create mode 100644 game/ato/flightplans/refuelingflightplan.py 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())