diff --git a/game/ato/flight.py b/game/ato/flight.py index db5f5d24..29c56ec6 100644 --- a/game/ato/flight.py +++ b/game/ato/flight.py @@ -27,6 +27,7 @@ if TYPE_CHECKING: from game.squadrons import Squadron, Pilot from game.theater import ControlPoint, MissionTarget from game.transfers import TransferOrder + from .flightplans.flightplan import FlightPlan from .flighttype import FlightType from .flightwaypoint import FlightWaypoint from .package import Package @@ -84,10 +85,11 @@ class Flight(SidcDescribable): # Will be replaced with a more appropriate FlightPlan by # FlightPlanBuilder, but an empty flight plan the flight begins with an # empty flight plan. - from game.ato.flightplans.flightplan import FlightPlan - from .flightplans.custom import CustomFlightPlan + from .flightplans.custom import CustomFlightPlan, CustomLayout - self.flight_plan: FlightPlan = CustomFlightPlan(self, []) + self.flight_plan: FlightPlan[Any] = CustomFlightPlan( + self, CustomLayout(custom_waypoints=[]) + ) def __getstate__(self) -> dict[str, Any]: state = self.__dict__.copy() @@ -196,9 +198,8 @@ class Flight(SidcDescribable): def abort(self) -> None: from .flightplans.rtb import RtbFlightPlan - self.flight_plan = RtbFlightPlan.builder_type()( - self, self.coalition.game.theater - ).build() + layout = RtbFlightPlan.builder_type()(self, self.coalition.game.theater).build() + self.flight_plan = RtbFlightPlan(self, layout) self.set_state( Navigating( diff --git a/game/ato/flightplans/aewc.py b/game/ato/flightplans/aewc.py index 82b9b659..3f78d680 100644 --- a/game/ato/flightplans/aewc.py +++ b/game/ato/flightplans/aewc.py @@ -4,17 +4,15 @@ from datetime import timedelta from typing import Type from game.ato.flightplans.ibuilder import IBuilder -from game.ato.flightplans.patrolling import PatrollingFlightPlan +from game.ato.flightplans.patrolling import PatrollingFlightPlan, PatrollingLayout from game.ato.flightplans.waypointbuilder import WaypointBuilder -from game.utils import Heading, feet, knots, meters, nautical_miles +from game.utils import Distance, Heading, Speed, feet, knots, meters, nautical_miles class Builder(IBuilder): - def build(self) -> AewcFlightPlan: + def build(self) -> PatrollingLayout: racetrack_half_distance = nautical_miles(30).meters - patrol_duration = timedelta(hours=4) - location = self.package.target closest_boundary = self.threat_zones.closest_boundary(location.position) @@ -52,15 +50,9 @@ class Builder(IBuilder): else: altitude = feet(25000) - if self.flight.unit_type.preferred_patrol_speed(altitude) is not None: - speed = self.flight.unit_type.preferred_patrol_speed(altitude) - else: - speed = knots(390) - racetrack = builder.race_track(racetrack_start, racetrack_end, altitude) - return AewcFlightPlan( - flight=self.flight, + return PatrollingLayout( departure=builder.takeoff(self.flight.departure), nav_to=builder.nav_path( self.flight.departure.position, racetrack_start, altitude @@ -73,15 +65,27 @@ class Builder(IBuilder): arrival=builder.land(self.flight.arrival), divert=builder.divert(self.flight.divert), bullseye=builder.bullseye(), - patrol_duration=patrol_duration, - patrol_speed=speed, - # TODO: Factor out a common base of the combat and non-combat race-tracks. - # No harm in setting this, but we ought to clean up a bit. - engagement_distance=meters(0), ) -class AewcFlightPlan(PatrollingFlightPlan): +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 diff --git a/game/ato/flightplans/airlift.py b/game/ato/flightplans/airlift.py index 7b2a7eed..f097d550 100644 --- a/game/ato/flightplans/airlift.py +++ b/game/ato/flightplans/airlift.py @@ -1,13 +1,14 @@ from __future__ import annotations from collections.abc import Iterator +from dataclasses import dataclass from datetime import timedelta from typing import TYPE_CHECKING, Type from game.utils import feet from .ibuilder import IBuilder from .planningerror import PlanningError -from .standard import StandardFlightPlan +from .standard import StandardFlightPlan, StandardLayout from .waypointbuilder import WaypointBuilder if TYPE_CHECKING: @@ -16,7 +17,7 @@ if TYPE_CHECKING: class Builder(IBuilder): - def build(self) -> AirliftFlightPlan: + def build(self) -> AirliftLayout: cargo = self.flight.cargo if cargo is None: raise PlanningError( @@ -39,8 +40,7 @@ class Builder(IBuilder): altitude_is_agl, ) - return AirliftFlightPlan( - flight=self.flight, + return AirliftLayout( departure=builder.takeoff(self.flight.departure), nav_to_pickup=nav_to_pickup, pickup=pickup, @@ -63,30 +63,13 @@ class Builder(IBuilder): ) -class AirliftFlightPlan(StandardFlightPlan): - def __init__( - self, - flight: Flight, - departure: FlightWaypoint, - nav_to_pickup: list[FlightWaypoint], - pickup: FlightWaypoint | None, - nav_to_drop_off: list[FlightWaypoint], - drop_off: FlightWaypoint, - nav_to_home: list[FlightWaypoint], - arrival: FlightWaypoint, - divert: FlightWaypoint | None, - bullseye: FlightWaypoint, - ) -> None: - super().__init__(flight, departure, arrival, divert, bullseye) - self.nav_to_pickup = nav_to_pickup - self.pickup = pickup - self.nav_to_drop_off = nav_to_drop_off - self.drop_off = drop_off - self.nav_to_home = nav_to_home - - @staticmethod - def builder_type() -> Type[Builder]: - return Builder +@dataclass(frozen=True) +class AirliftLayout(StandardLayout): + nav_to_pickup: list[FlightWaypoint] + pickup: FlightWaypoint | None + nav_to_drop_off: list[FlightWaypoint] + drop_off: FlightWaypoint + nav_to_home: list[FlightWaypoint] def iter_waypoints(self) -> Iterator[FlightWaypoint]: yield self.departure @@ -101,9 +84,18 @@ class AirliftFlightPlan(StandardFlightPlan): 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.drop_off + 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 diff --git a/game/ato/flightplans/antiship.py b/game/ato/flightplans/antiship.py index 505ba322..4326ecc2 100644 --- a/game/ato/flightplans/antiship.py +++ b/game/ato/flightplans/antiship.py @@ -4,7 +4,11 @@ from typing import Type from game.theater import NavalControlPoint from game.theater.theatergroundobject import NavalGroundObject -from .formationattack import FormationAttackBuilder, FormationAttackFlightPlan +from .formationattack import ( + FormationAttackBuilder, + FormationAttackFlightPlan, + FormationAttackLayout, +) from .invalidobjectivelocation import InvalidObjectiveLocation from .waypointbuilder import StrikeTarget from ..flightwaypointtype import FlightWaypointType @@ -16,8 +20,8 @@ class AntiShipFlightPlan(FormationAttackFlightPlan): return Builder -class Builder(FormationAttackBuilder[AntiShipFlightPlan]): - def build(self) -> FormationAttackFlightPlan: +class Builder(FormationAttackBuilder): + def build(self) -> FormationAttackLayout: location = self.package.target from game.transfers import CargoShip @@ -31,7 +35,7 @@ class Builder(FormationAttackBuilder[AntiShipFlightPlan]): else: raise InvalidObjectiveLocation(self.flight.flight_type, location) - return self._build(AntiShipFlightPlan, FlightWaypointType.INGRESS_BAI, targets) + return self._build(FlightWaypointType.INGRESS_BAI, targets) @staticmethod def anti_ship_targets_for_tgo(tgo: NavalGroundObject) -> list[StrikeTarget]: diff --git a/game/ato/flightplans/bai.py b/game/ato/flightplans/bai.py index 9fb164c6..837e7352 100644 --- a/game/ato/flightplans/bai.py +++ b/game/ato/flightplans/bai.py @@ -3,7 +3,11 @@ from __future__ import annotations from typing import Type from game.theater.theatergroundobject import TheaterGroundObject -from .formationattack import FormationAttackBuilder, FormationAttackFlightPlan +from .formationattack import ( + FormationAttackBuilder, + FormationAttackFlightPlan, + FormationAttackLayout, +) from .invalidobjectivelocation import InvalidObjectiveLocation from .waypointbuilder import StrikeTarget from ..flightwaypointtype import FlightWaypointType @@ -15,8 +19,8 @@ class BaiFlightPlan(FormationAttackFlightPlan): return Builder -class Builder(FormationAttackBuilder[BaiFlightPlan]): - def build(self) -> FormationAttackFlightPlan: +class Builder(FormationAttackBuilder): + def build(self) -> FormationAttackLayout: location = self.package.target from game.transfers import Convoy @@ -33,4 +37,4 @@ class Builder(FormationAttackBuilder[BaiFlightPlan]): else: raise InvalidObjectiveLocation(self.flight.flight_type, location) - return self._build(BaiFlightPlan, FlightWaypointType.INGRESS_BAI, targets) + return self._build(FlightWaypointType.INGRESS_BAI, targets) diff --git a/game/ato/flightplans/barcap.py b/game/ato/flightplans/barcap.py index 76ea3064..b2f4abe3 100644 --- a/game/ato/flightplans/barcap.py +++ b/game/ato/flightplans/barcap.py @@ -1,18 +1,19 @@ from __future__ import annotations import random +from datetime import timedelta from typing import Type from game.theater import FrontLine -from game.utils import feet +from game.utils import Distance, Speed, feet from .capbuilder import CapBuilder from .invalidobjectivelocation import InvalidObjectiveLocation -from .patrolling import PatrollingFlightPlan +from .patrolling import PatrollingFlightPlan, PatrollingLayout from .waypointbuilder import WaypointBuilder class Builder(CapBuilder): - def build(self) -> BarCapFlightPlan: + def build(self) -> PatrollingLayout: location = self.package.target if isinstance(location, FrontLine): @@ -27,16 +28,10 @@ class Builder(CapBuilder): min(self.doctrine.max_patrol_altitude, randomized_alt), ) - patrol_speed = self.flight.unit_type.preferred_patrol_speed(patrol_alt) - builder = WaypointBuilder(self.flight, self.coalition) start, end = builder.race_track(start_pos, end_pos, patrol_alt) - return BarCapFlightPlan( - flight=self.flight, - patrol_duration=self.doctrine.cap_duration, - patrol_speed=patrol_speed, - engagement_distance=self.doctrine.cap_engagement_range, + return PatrollingLayout( departure=builder.takeoff(self.flight.departure), nav_to=builder.nav_path( self.flight.departure.position, start.position, patrol_alt @@ -52,7 +47,21 @@ class Builder(CapBuilder): ) -class BarCapFlightPlan(PatrollingFlightPlan): +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 diff --git a/game/ato/flightplans/cas.py b/game/ato/flightplans/cas.py index 608b81d1..9c24219f 100644 --- a/game/ato/flightplans/cas.py +++ b/game/ato/flightplans/cas.py @@ -1,24 +1,24 @@ from __future__ import annotations from collections.abc import Iterator +from dataclasses import dataclass from datetime import timedelta from typing import TYPE_CHECKING, Type from game.theater import FrontLine -from game.utils import Distance, Speed, meters +from game.utils import Distance, Speed, kph, meters from .ibuilder import IBuilder from .invalidobjectivelocation import InvalidObjectiveLocation -from .patrolling import PatrollingFlightPlan +from .patrolling import PatrollingFlightPlan, PatrollingLayout from .waypointbuilder import WaypointBuilder from ..flightwaypointtype import FlightWaypointType if TYPE_CHECKING: - from ..flight import Flight from ..flightwaypoint import FlightWaypoint class Builder(IBuilder): - def build(self) -> CasFlightPlan: + def build(self) -> CasLayout: location = self.package.target if not isinstance(location, FrontLine): @@ -41,24 +41,13 @@ class Builder(IBuilder): builder = WaypointBuilder(self.flight, self.coalition) - # 2021-08-02: patrol_speed will currently have no effect because - # CAS doesn't use OrbitAction. But all PatrollingFlightPlan are expected - # to have patrol_speed is_helo = self.flight.unit_type.dcs_unit_type.helicopter ingress_egress_altitude = ( self.doctrine.ingress_altitude if not is_helo else meters(50) ) - patrol_speed = self.flight.unit_type.preferred_patrol_speed( - ingress_egress_altitude - ) use_agl_ingress_egress = is_helo - from game.missiongenerator.frontlineconflictdescription import FRONTLINE_LENGTH - - return CasFlightPlan( - flight=self.flight, - patrol_duration=self.doctrine.cas_duration, - patrol_speed=patrol_speed, + return CasLayout( departure=builder.takeoff(self.flight.departure), nav_to=builder.nav_path( self.flight.departure.position, @@ -75,7 +64,6 @@ class Builder(IBuilder): patrol_start=builder.ingress( FlightWaypointType.INGRESS_CAS, ingress, location ), - engagement_distance=meters(FRONTLINE_LENGTH) / 2, target=builder.cas(center), patrol_end=builder.egress(egress, location), arrival=builder.land(self.flight.arrival), @@ -84,42 +72,9 @@ class Builder(IBuilder): ) -class CasFlightPlan(PatrollingFlightPlan): - def __init__( - self, - flight: Flight, - departure: FlightWaypoint, - arrival: FlightWaypoint, - divert: FlightWaypoint | None, - bullseye: FlightWaypoint, - nav_to: list[FlightWaypoint], - nav_from: list[FlightWaypoint], - patrol_start: FlightWaypoint, - patrol_end: FlightWaypoint, - patrol_duration: timedelta, - patrol_speed: Speed, - engagement_distance: Distance, - target: FlightWaypoint, - ) -> None: - super().__init__( - flight, - departure, - arrival, - divert, - bullseye, - nav_to, - nav_from, - patrol_start, - patrol_end, - patrol_duration, - patrol_speed, - engagement_distance, - ) - self.target = target - - @staticmethod - def builder_type() -> Type[Builder]: - return Builder +@dataclass(frozen=True) +class CasLayout(PatrollingLayout): + target: FlightWaypoint def iter_waypoints(self) -> Iterator[FlightWaypoint]: yield self.departure @@ -133,12 +88,35 @@ class CasFlightPlan(PatrollingFlightPlan): 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.patrol_start, self.target, self.patrol_end} + return {self.layout.patrol_start, self.layout.target, self.layout.patrol_end} def request_escort_at(self) -> FlightWaypoint | None: - return self.patrol_start + return self.layout.patrol_start def dismiss_escort_at(self) -> FlightWaypoint | None: - return self.patrol_end + return self.layout.patrol_end diff --git a/game/ato/flightplans/custom.py b/game/ato/flightplans/custom.py index 8cbafbee..652a9cae 100644 --- a/game/ato/flightplans/custom.py +++ b/game/ato/flightplans/custom.py @@ -1,35 +1,36 @@ from __future__ import annotations from collections.abc import Iterator +from dataclasses import dataclass from datetime import timedelta from typing import TYPE_CHECKING, Type -from .flightplan import FlightPlan +from .flightplan import FlightPlan, Layout from .ibuilder import IBuilder from ..flightwaypointtype import FlightWaypointType if TYPE_CHECKING: - from ..flight import Flight from ..flightwaypoint import FlightWaypoint class Builder(IBuilder): - def build(self) -> CustomFlightPlan: - return CustomFlightPlan(self.flight, []) + def build(self) -> CustomLayout: + return CustomLayout([]) -class CustomFlightPlan(FlightPlan): - def __init__(self, flight: Flight, waypoints: list[FlightWaypoint]) -> None: - super().__init__(flight) - self.custom_waypoints = waypoints - - @staticmethod - def builder_type() -> Type[Builder]: - return Builder +@dataclass(frozen=True) +class CustomLayout(Layout): + custom_waypoints: list[FlightWaypoint] def iter_waypoints(self) -> Iterator[FlightWaypoint]: yield from self.custom_waypoints + +class CustomFlightPlan(FlightPlan[CustomLayout]): + @staticmethod + def builder_type() -> Type[Builder]: + return Builder + @property def tot_waypoint(self) -> FlightWaypoint | None: target_types = ( diff --git a/game/ato/flightplans/dead.py b/game/ato/flightplans/dead.py index c6949385..b3a38a54 100644 --- a/game/ato/flightplans/dead.py +++ b/game/ato/flightplans/dead.py @@ -7,7 +7,11 @@ from game.theater.theatergroundobject import ( EwrGroundObject, SamGroundObject, ) -from .formationattack import FormationAttackBuilder, FormationAttackFlightPlan +from .formationattack import ( + FormationAttackBuilder, + FormationAttackFlightPlan, + FormationAttackLayout, +) from .invalidobjectivelocation import InvalidObjectiveLocation from ..flightwaypointtype import FlightWaypointType @@ -18,8 +22,8 @@ class DeadFlightPlan(FormationAttackFlightPlan): return Builder -class Builder(FormationAttackBuilder[DeadFlightPlan]): - def build(self) -> FormationAttackFlightPlan: +class Builder(FormationAttackBuilder): + def build(self) -> FormationAttackLayout: location = self.package.target is_ewr = isinstance(location, EwrGroundObject) @@ -31,4 +35,4 @@ class Builder(FormationAttackBuilder[DeadFlightPlan]): ) raise InvalidObjectiveLocation(self.flight.flight_type, location) - return self._build(DeadFlightPlan, FlightWaypointType.INGRESS_DEAD) + return self._build(FlightWaypointType.INGRESS_DEAD) diff --git a/game/ato/flightplans/escort.py b/game/ato/flightplans/escort.py index 0304fb69..a1008ce3 100644 --- a/game/ato/flightplans/escort.py +++ b/game/ato/flightplans/escort.py @@ -1,9 +1,12 @@ from __future__ import annotations -from datetime import timedelta from typing import Type -from .formationattack import FormationAttackBuilder, FormationAttackFlightPlan +from .formationattack import ( + FormationAttackBuilder, + FormationAttackFlightPlan, + FormationAttackLayout, +) from .waypointbuilder import WaypointBuilder @@ -13,8 +16,8 @@ class EscortFlightPlan(FormationAttackFlightPlan): return Builder -class Builder(FormationAttackBuilder[EscortFlightPlan]): - def build(self) -> FormationAttackFlightPlan: +class Builder(FormationAttackBuilder): + def build(self) -> FormationAttackLayout: assert self.package.waypoints is not None builder = WaypointBuilder(self.flight, self.coalition) @@ -28,11 +31,9 @@ class Builder(FormationAttackBuilder[EscortFlightPlan]): if self.package.waypoints.refuel is not None: refuel = builder.refuel(self.package.waypoints.refuel) - return EscortFlightPlan( - flight=self.flight, + return FormationAttackLayout( departure=builder.takeoff(self.flight.departure), hold=hold, - hold_duration=timedelta(minutes=5), nav_to=builder.nav_path( hold.position, join.position, self.doctrine.ingress_altitude ), diff --git a/game/ato/flightplans/ferry.py b/game/ato/flightplans/ferry.py index 08c98ce8..5c27cd49 100644 --- a/game/ato/flightplans/ferry.py +++ b/game/ato/flightplans/ferry.py @@ -1,22 +1,22 @@ from __future__ import annotations from collections.abc import Iterator +from dataclasses import dataclass from datetime import timedelta from typing import TYPE_CHECKING, Type from game.utils import feet from .ibuilder import IBuilder from .planningerror import PlanningError -from .standard import StandardFlightPlan +from .standard import StandardFlightPlan, StandardLayout from .waypointbuilder import WaypointBuilder if TYPE_CHECKING: - from ..flight import Flight from ..flightwaypoint import FlightWaypoint class Builder(IBuilder): - def build(self) -> FerryFlightPlan: + def build(self) -> FerryLayout: if self.flight.departure == self.flight.arrival: raise PlanningError( f"Cannot plan ferry self.flight: departure and arrival are both " @@ -31,8 +31,7 @@ class Builder(IBuilder): ) builder = WaypointBuilder(self.flight, self.coalition) - return FerryFlightPlan( - flight=self.flight, + return FerryLayout( departure=builder.takeoff(self.flight.departure), nav_to_destination=builder.nav_path( self.flight.departure.position, @@ -46,22 +45,9 @@ class Builder(IBuilder): ) -class FerryFlightPlan(StandardFlightPlan): - def __init__( - self, - flight: Flight, - departure: FlightWaypoint, - arrival: FlightWaypoint, - divert: FlightWaypoint | None, - bullseye: FlightWaypoint, - nav_to_destination: list[FlightWaypoint], - ) -> None: - super().__init__(flight, departure, arrival, divert, bullseye) - self.nav_to_destination = nav_to_destination - - @staticmethod - def builder_type() -> Type[Builder]: - return Builder +@dataclass(frozen=True) +class FerryLayout(StandardLayout): + nav_to_destination: list[FlightWaypoint] def iter_waypoints(self) -> Iterator[FlightWaypoint]: yield self.departure @@ -71,9 +57,15 @@ class FerryFlightPlan(StandardFlightPlan): yield self.divert yield self.bullseye + +class FerryFlightPlan(StandardFlightPlan[FerryLayout]): + @staticmethod + def builder_type() -> Type[Builder]: + return Builder + @property def tot_waypoint(self) -> FlightWaypoint | None: - return self.arrival + return self.layout.arrival def tot_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None: # TOT planning isn't really useful for ferries. They're behind the front diff --git a/game/ato/flightplans/flightplan.py b/game/ato/flightplans/flightplan.py index 5c7f33fe..3021b658 100644 --- a/game/ato/flightplans/flightplan.py +++ b/game/ato/flightplans/flightplan.py @@ -12,7 +12,7 @@ from abc import ABC, abstractmethod from collections.abc import Iterator from datetime import timedelta from functools import cached_property -from typing import TYPE_CHECKING, Type, TypeGuard +from typing import Any, Generic, TYPE_CHECKING, Type, TypeGuard, TypeVar from game.typeguard import self_type_guard from game.utils import Distance, Speed, meters @@ -41,9 +41,24 @@ INGRESS_TYPES = { } -class FlightPlan(ABC): - def __init__(self, flight: Flight) -> None: +class Layout(ABC): + @property + def waypoints(self) -> list[FlightWaypoint]: + """A list of all waypoints in the flight plan, in order.""" + return list(self.iter_waypoints()) + + def iter_waypoints(self) -> Iterator[FlightWaypoint]: + """Iterates over all waypoints in the flight plan, in order.""" + raise NotImplementedError + + +LayoutT = TypeVar("LayoutT", bound=Layout) + + +class FlightPlan(ABC, Generic[LayoutT]): + def __init__(self, flight: Flight, layout: LayoutT) -> None: self.flight = flight + self.layout = layout @property def package(self) -> Package: @@ -61,7 +76,7 @@ class FlightPlan(ABC): def iter_waypoints(self) -> Iterator[FlightWaypoint]: """Iterates over all waypoints in the flight plan, in order.""" - raise NotImplementedError + yield from self.layout.iter_waypoints() def edges( self, until: FlightWaypoint | None = None @@ -296,13 +311,17 @@ class FlightPlan(ABC): raise NotImplementedError @self_type_guard - def is_loiter(self, flight_plan: FlightPlan) -> TypeGuard[LoiterFlightPlan]: + def is_loiter(self, flight_plan: FlightPlan[Any]) -> TypeGuard[LoiterFlightPlan]: return False @self_type_guard - def is_patrol(self, flight_plan: FlightPlan) -> TypeGuard[PatrollingFlightPlan]: + def is_patrol( + self, flight_plan: FlightPlan[Any] + ) -> TypeGuard[PatrollingFlightPlan[Any]]: return False @self_type_guard - def is_formation(self, flight_plan: FlightPlan) -> TypeGuard[FormationFlightPlan]: + def is_formation( + self, flight_plan: FlightPlan[Any] + ) -> TypeGuard[FormationFlightPlan]: return False diff --git a/game/ato/flightplans/flightplanbuilder.py b/game/ato/flightplans/flightplanbuilder.py index 3f4ca46e..5e93c928 100644 --- a/game/ato/flightplans/flightplanbuilder.py +++ b/game/ato/flightplans/flightplanbuilder.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Type +from typing import Any, TYPE_CHECKING, Type from game.ato import FlightType from game.ato.closestairfields import ObjectiveDistanceCache @@ -82,8 +82,8 @@ class FlightPlanBuilder: f"{flight.departure} to {flight.package.target}" ) from ex - def plan_type(self, task: FlightType) -> Type[FlightPlan] | None: - plan_type: Type[FlightPlan] + def plan_type(self, task: FlightType) -> Type[FlightPlan[Any]] | None: + plan_type: Type[FlightPlan[Any]] if task == FlightType.REFUELING: if self.package.target.is_friendly(self.is_player) or isinstance( self.package.target, FrontLine @@ -91,7 +91,7 @@ class FlightPlanBuilder: return TheaterRefuelingFlightPlan return PackageRefuelingFlightPlan - plan_dict: dict[FlightType, Type[FlightPlan]] = { + plan_dict: dict[FlightType, Type[FlightPlan[Any]]] = { FlightType.ANTISHIP: AntiShipFlightPlan, FlightType.BAI: BaiFlightPlan, FlightType.BARCAP: BarCapFlightPlan, @@ -111,13 +111,14 @@ class FlightPlanBuilder: } return plan_dict.get(task) - def generate_flight_plan(self, flight: Flight) -> FlightPlan: + def generate_flight_plan(self, flight: Flight) -> FlightPlan[Any]: plan_type = self.plan_type(flight.flight_type) if plan_type is None: raise PlanningError( f"{flight.flight_type} flight plan generation not implemented" ) - return plan_type.builder_type()(flight, self.theater).build() + layout = plan_type.builder_type()(flight, self.theater).build() + return plan_type(flight, layout) def regenerate_flight_plans(self) -> None: new_flights: list[Flight] = [] diff --git a/game/ato/flightplans/formation.py b/game/ato/flightplans/formation.py index bb5df82e..5aa39263 100644 --- a/game/ato/flightplans/formation.py +++ b/game/ato/flightplans/formation.py @@ -1,52 +1,31 @@ from __future__ import annotations from abc import ABC, abstractmethod +from dataclasses import dataclass from datetime import timedelta from functools import cached_property -from typing import TYPE_CHECKING, TypeGuard +from typing import Any, TYPE_CHECKING, TypeGuard from game.typeguard import self_type_guard from game.utils import Speed from .flightplan import FlightPlan -from .loiter import LoiterFlightPlan +from .loiter import LoiterFlightPlan, LoiterLayout from ..traveltime import GroundSpeed, TravelTime if TYPE_CHECKING: - from ..flight import Flight from ..flightwaypoint import FlightWaypoint -class FormationFlightPlan(LoiterFlightPlan, ABC): - def __init__( - self, - flight: Flight, - departure: FlightWaypoint, - arrival: FlightWaypoint, - divert: FlightWaypoint | None, - bullseye: FlightWaypoint, - nav_to: list[FlightWaypoint], - nav_from: list[FlightWaypoint], - hold: FlightWaypoint, - hold_duration: timedelta, - join: FlightWaypoint, - split: FlightWaypoint, - refuel: FlightWaypoint, - ) -> None: - super().__init__( - flight, - departure, - arrival, - divert, - bullseye, - nav_to, - nav_from, - hold, - hold_duration, - ) - self.join = join - self.split = split - self.refuel = refuel +@dataclass(frozen=True) +class FormationLayout(LoiterLayout, ABC): + nav_to: list[FlightWaypoint] + join: FlightWaypoint + split: FlightWaypoint + refuel: FlightWaypoint + nav_from: list[FlightWaypoint] + +class FormationFlightPlan(LoiterFlightPlan, ABC): @property @abstractmethod def package_speed_waypoints(self) -> set[FlightWaypoint]: @@ -57,10 +36,10 @@ class FormationFlightPlan(LoiterFlightPlan, ABC): return self.package_speed_waypoints def request_escort_at(self) -> FlightWaypoint | None: - return self.join + return self.layout.join def dismiss_escort_at(self) -> FlightWaypoint | None: - return self.split + return self.layout.split @cached_property def best_flight_formation_speed(self) -> Speed: @@ -90,7 +69,7 @@ class FormationFlightPlan(LoiterFlightPlan, ABC): @property def travel_time_to_rendezvous(self) -> timedelta: """The estimated time between the first waypoint and the join point.""" - return self._travel_time_to_waypoint(self.join) + return self._travel_time_to_waypoint(self.layout.join) @property @abstractmethod @@ -103,18 +82,18 @@ class FormationFlightPlan(LoiterFlightPlan, ABC): ... def tot_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None: - if waypoint == self.join: + if waypoint == self.layout.join: return self.join_time - elif waypoint == self.split: + elif waypoint == self.layout.split: return self.split_time return None @property def push_time(self) -> timedelta: return self.join_time - TravelTime.between_points( - self.hold.position, - self.join.position, - GroundSpeed.for_flight(self.flight, self.hold.alt), + self.layout.hold.position, + self.layout.join.position, + GroundSpeed.for_flight(self.flight, self.layout.hold.alt), ) @property @@ -122,5 +101,7 @@ class FormationFlightPlan(LoiterFlightPlan, ABC): return self.split_time @self_type_guard - def is_formation(self, flight_plan: FlightPlan) -> TypeGuard[FormationFlightPlan]: + def is_formation( + self, flight_plan: FlightPlan[Any] + ) -> TypeGuard[FormationFlightPlan]: return True diff --git a/game/ato/flightplans/formationattack.py b/game/ato/flightplans/formationattack.py index 6be043f2..20d1cdd4 100644 --- a/game/ato/flightplans/formationattack.py +++ b/game/ato/flightplans/formationattack.py @@ -2,15 +2,16 @@ from __future__ import annotations from abc import ABC from collections.abc import Iterator +from dataclasses import dataclass from datetime import timedelta -from typing import Generic, TYPE_CHECKING, Type, TypeVar +from typing import TYPE_CHECKING, TypeVar from dcs import Point from game.flightplan import HoldZoneGeometry from game.theater import MissionTarget from game.utils import Speed, meters -from .formation import FormationFlightPlan +from .formation import FormationFlightPlan, FormationLayout from .ibuilder import IBuilder from .planningerror import PlanningError from .waypointbuilder import StrikeTarget, WaypointBuilder @@ -23,64 +24,16 @@ if TYPE_CHECKING: class FormationAttackFlightPlan(FormationFlightPlan, ABC): - def __init__( - self, - flight: Flight, - departure: FlightWaypoint, - arrival: FlightWaypoint, - divert: FlightWaypoint | None, - bullseye: FlightWaypoint, - nav_to: list[FlightWaypoint], - nav_from: list[FlightWaypoint], - hold: FlightWaypoint, - hold_duration: timedelta, - join: FlightWaypoint, - split: FlightWaypoint, - refuel: FlightWaypoint, - ingress: FlightWaypoint, - targets: list[FlightWaypoint], - lead_time: timedelta = timedelta(), - ) -> None: - super().__init__( - flight, - departure, - arrival, - divert, - bullseye, - nav_to, - nav_from, - hold, - hold_duration, - join, - split, - refuel, - ) - self.ingress = ingress - self.targets = targets - self.lead_time = lead_time - - def iter_waypoints(self) -> Iterator[FlightWaypoint]: - yield self.departure - yield self.hold - yield from self.nav_to - yield self.join - yield self.ingress - yield from self.targets - yield self.split - if self.refuel is not None: - yield self.refuel - yield from self.nav_from - yield self.arrival - if self.divert is not None: - yield self.divert - yield self.bullseye + @property + def lead_time(self) -> timedelta: + return timedelta() @property def package_speed_waypoints(self) -> set[FlightWaypoint]: return { - self.ingress, - self.split, - } | set(self.targets) + self.layout.ingress, + self.layout.split, + } | set(self.layout.targets) def speed_between_waypoints(self, a: FlightWaypoint, b: FlightWaypoint) -> Speed: # FlightWaypoint is only comparable by identity, so adding @@ -94,7 +47,7 @@ class FormationAttackFlightPlan(FormationFlightPlan, ABC): @property def tot_waypoint(self) -> FlightWaypoint: - return self.targets[0] + return self.layout.targets[0] @property def tot_offset(self) -> timedelta: @@ -138,18 +91,20 @@ class FormationAttackFlightPlan(FormationFlightPlan, ABC): @property def join_time(self) -> timedelta: - travel_time = self.travel_time_between_waypoints(self.join, self.ingress) + travel_time = self.travel_time_between_waypoints( + self.layout.join, self.layout.ingress + ) return self.ingress_time - travel_time @property def split_time(self) -> timedelta: travel_time_ingress = self.travel_time_between_waypoints( - self.ingress, self.target_area_waypoint + self.layout.ingress, self.target_area_waypoint ) travel_time_egress = self.travel_time_between_waypoints( - self.target_area_waypoint, self.split + self.target_area_waypoint, self.layout.split ) - minutes_at_target = 0.75 * len(self.targets) + minutes_at_target = 0.75 * len(self.layout.targets) timedelta_at_target = timedelta(minutes=minutes_at_target) return ( self.ingress_time @@ -162,29 +117,49 @@ class FormationAttackFlightPlan(FormationFlightPlan, ABC): def ingress_time(self) -> timedelta: tot = self.tot travel_time = self.travel_time_between_waypoints( - self.ingress, self.target_area_waypoint + self.layout.ingress, self.target_area_waypoint ) return tot - travel_time def tot_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None: - if waypoint == self.ingress: + if waypoint == self.layout.ingress: return self.ingress_time - elif waypoint in self.targets: + elif waypoint in self.layout.targets: return self.tot return super().tot_for_waypoint(waypoint) -FlightPlanT = TypeVar("FlightPlanT", bound=FormationAttackFlightPlan) +@dataclass(frozen=True) +class FormationAttackLayout(FormationLayout): + ingress: FlightWaypoint + targets: list[FlightWaypoint] + + def iter_waypoints(self) -> Iterator[FlightWaypoint]: + yield self.departure + yield self.hold + yield from self.nav_to + yield self.join + yield self.ingress + yield from self.targets + yield self.split + if self.refuel is not None: + yield self.refuel + yield from self.nav_from + yield self.arrival + if self.divert is not None: + yield self.divert + yield self.bullseye -class FormationAttackBuilder(IBuilder, ABC, Generic[FlightPlanT]): +LayoutT = TypeVar("LayoutT", bound=FormationAttackLayout) + + +class FormationAttackBuilder(IBuilder, ABC): def _build( self, - plan_type: Type[FlightPlanT], ingress_type: FlightWaypointType, targets: list[StrikeTarget] | None = None, - lead_time: timedelta = timedelta(), - ) -> FlightPlanT: + ) -> FormationAttackLayout: assert self.package.waypoints is not None builder = WaypointBuilder(self.flight, self.coalition, targets) @@ -208,11 +183,9 @@ class FormationAttackBuilder(IBuilder, ABC, Generic[FlightPlanT]): if self.package.waypoints.refuel is not None: refuel = builder.refuel(self.package.waypoints.refuel) - return plan_type( - flight=self.flight, + return FormationAttackLayout( departure=builder.takeoff(self.flight.departure), hold=hold, - hold_duration=timedelta(minutes=5), nav_to=builder.nav_path( hold.position, join.position, self.doctrine.ingress_altitude ), @@ -231,7 +204,6 @@ class FormationAttackBuilder(IBuilder, ABC, Generic[FlightPlanT]): arrival=builder.land(self.flight.arrival), divert=builder.divert(self.flight.divert), bullseye=builder.bullseye(), - lead_time=lead_time, ) @staticmethod diff --git a/game/ato/flightplans/ibuilder.py b/game/ato/flightplans/ibuilder.py index c0e9ff28..3ef5de2f 100644 --- a/game/ato/flightplans/ibuilder.py +++ b/game/ato/flightplans/ibuilder.py @@ -10,7 +10,7 @@ if TYPE_CHECKING: from game.threatzones import ThreatZones from ..flight import Flight from ..package import Package - from .flightplan import FlightPlan + from .flightplan import Layout class IBuilder(ABC): @@ -19,7 +19,7 @@ class IBuilder(ABC): self.theater = theater @abstractmethod - def build(self) -> FlightPlan: + def build(self) -> Layout: ... @property diff --git a/game/ato/flightplans/loiter.py b/game/ato/flightplans/loiter.py index 3a30f968..c0e4ce56 100644 --- a/game/ato/flightplans/loiter.py +++ b/game/ato/flightplans/loiter.py @@ -1,36 +1,27 @@ from __future__ import annotations from abc import ABC, abstractmethod +from dataclasses import dataclass from datetime import timedelta -from typing import TYPE_CHECKING, TypeGuard +from typing import Any, TYPE_CHECKING, TypeGuard from game.typeguard import self_type_guard from .flightplan import FlightPlan -from .standard import StandardFlightPlan +from .standard import StandardFlightPlan, StandardLayout if TYPE_CHECKING: - from ..flight import Flight from ..flightwaypoint import FlightWaypoint -class LoiterFlightPlan(StandardFlightPlan, ABC): - def __init__( - self, - flight: Flight, - departure: FlightWaypoint, - arrival: FlightWaypoint, - divert: FlightWaypoint | None, - bullseye: FlightWaypoint, - nav_to: list[FlightWaypoint], - nav_from: list[FlightWaypoint], - hold: FlightWaypoint, - hold_duration: timedelta, - ) -> None: - super().__init__(flight, departure, arrival, divert, bullseye) - self.nav_to = nav_to - self.nav_from = nav_from - self.hold = hold - self.hold_duration = hold_duration +@dataclass(frozen=True) +class LoiterLayout(StandardLayout, ABC): + hold: FlightWaypoint + + +class LoiterFlightPlan(StandardFlightPlan[Any], ABC): + @property + def hold_duration(self) -> timedelta: + return timedelta(minutes=5) @property @abstractmethod @@ -38,7 +29,7 @@ class LoiterFlightPlan(StandardFlightPlan, ABC): ... def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None: - if waypoint == self.hold: + if waypoint == self.layout.hold: return self.push_time return None @@ -46,10 +37,10 @@ class LoiterFlightPlan(StandardFlightPlan, ABC): self, a: FlightWaypoint, b: FlightWaypoint ) -> timedelta: travel_time = super().travel_time_between_waypoints(a, b) - if a != self.hold: + if a != self.layout.hold: return travel_time return travel_time + self.hold_duration @self_type_guard - def is_loiter(self, flight_plan: FlightPlan) -> TypeGuard[LoiterFlightPlan]: + def is_loiter(self, flight_plan: FlightPlan[Any]) -> TypeGuard[LoiterFlightPlan]: return True diff --git a/game/ato/flightplans/ocaaircraft.py b/game/ato/flightplans/ocaaircraft.py index b831a5e0..f8de4648 100644 --- a/game/ato/flightplans/ocaaircraft.py +++ b/game/ato/flightplans/ocaaircraft.py @@ -4,7 +4,11 @@ import logging from typing import Type from game.theater import Airfield -from .formationattack import FormationAttackBuilder, FormationAttackFlightPlan +from .formationattack import ( + FormationAttackBuilder, + FormationAttackFlightPlan, + FormationAttackLayout, +) from .invalidobjectivelocation import InvalidObjectiveLocation from ..flightwaypointtype import FlightWaypointType @@ -15,8 +19,8 @@ class OcaAircraftFlightPlan(FormationAttackFlightPlan): return Builder -class Builder(FormationAttackBuilder[OcaAircraftFlightPlan]): - def build(self) -> FormationAttackFlightPlan: +class Builder(FormationAttackBuilder): + def build(self) -> FormationAttackLayout: location = self.package.target if not isinstance(location, Airfield): @@ -26,6 +30,4 @@ class Builder(FormationAttackBuilder[OcaAircraftFlightPlan]): ) raise InvalidObjectiveLocation(self.flight.flight_type, location) - return self._build( - OcaAircraftFlightPlan, FlightWaypointType.INGRESS_OCA_AIRCRAFT - ) + return self._build(FlightWaypointType.INGRESS_OCA_AIRCRAFT) diff --git a/game/ato/flightplans/ocarunway.py b/game/ato/flightplans/ocarunway.py index feabbc17..97d49cef 100644 --- a/game/ato/flightplans/ocarunway.py +++ b/game/ato/flightplans/ocarunway.py @@ -4,7 +4,11 @@ import logging from typing import Type from game.theater import Airfield -from .formationattack import FormationAttackBuilder, FormationAttackFlightPlan +from .formationattack import ( + FormationAttackBuilder, + FormationAttackFlightPlan, + FormationAttackLayout, +) from .invalidobjectivelocation import InvalidObjectiveLocation from ..flightwaypointtype import FlightWaypointType @@ -15,8 +19,8 @@ class OcaRunwayFlightPlan(FormationAttackFlightPlan): return Builder -class Builder(FormationAttackBuilder[OcaRunwayFlightPlan]): - def build(self) -> FormationAttackFlightPlan: +class Builder(FormationAttackBuilder): + def build(self) -> FormationAttackLayout: location = self.package.target if not isinstance(location, Airfield): @@ -26,4 +30,4 @@ class Builder(FormationAttackBuilder[OcaRunwayFlightPlan]): ) raise InvalidObjectiveLocation(self.flight.flight_type, location) - return self._build(OcaRunwayFlightPlan, FlightWaypointType.INGRESS_OCA_RUNWAY) + return self._build(FlightWaypointType.INGRESS_OCA_RUNWAY) diff --git a/game/ato/flightplans/packagerefueling.py b/game/ato/flightplans/packagerefueling.py index 5683bd32..d63796f8 100644 --- a/game/ato/flightplans/packagerefueling.py +++ b/game/ato/flightplans/packagerefueling.py @@ -5,7 +5,8 @@ from typing import Type from dcs import Point -from game.utils import Distance, Heading, feet, knots, meters +from game.utils import Distance, Heading, feet, meters +from .patrolling import PatrollingLayout from .theaterrefueling import ( Builder as TheaterRefuelingBuilder, TheaterRefuelingFlightPlan, @@ -16,18 +17,11 @@ from ..flightwaypointtype import FlightWaypointType class Builder(TheaterRefuelingBuilder): - def build(self) -> PackageRefuelingFlightPlan: + def build(self) -> PatrollingLayout: package_waypoints = self.package.waypoints assert package_waypoints is not None racetrack_half_distance = Distance.from_nautical_miles(20).meters - # TODO: Only consider aircraft that can refuel with this tanker type. - refuel_time_minutes = 5 - for self.flight in self.package.flights: - flight_size = self.flight.roster.max_size - refuel_time_minutes = refuel_time_minutes + 4 * flight_size + 1 - - patrol_duration = timedelta(minutes=refuel_time_minutes) racetrack_center = package_waypoints.refuel @@ -52,17 +46,9 @@ class Builder(TheaterRefuelingBuilder): else: altitude = feet(21000) - # TODO: Could use self.flight.unit_type.preferred_patrol_speed(altitude). - if tanker_type.patrol_speed is not None: - speed = tanker_type.patrol_speed - else: - # ~280 knots IAS at 21000. - speed = knots(400) - racetrack = builder.race_track(racetrack_start, racetrack_end, altitude) - return PackageRefuelingFlightPlan( - flight=self.flight, + return PatrollingLayout( departure=builder.takeoff(self.flight.departure), nav_to=builder.nav_path( self.flight.departure.position, racetrack_start, altitude @@ -75,11 +61,6 @@ class Builder(TheaterRefuelingBuilder): arrival=builder.land(self.flight.arrival), divert=builder.divert(self.flight.divert), bullseye=builder.bullseye(), - patrol_duration=patrol_duration, - patrol_speed=speed, - # TODO: Factor out a common base of the combat and non-combat race-tracks. - # No harm in setting this, but we ought to clean up a bit. - engagement_distance=meters(0), ) @@ -88,6 +69,16 @@ class PackageRefuelingFlightPlan(TheaterRefuelingFlightPlan): def builder_type() -> Type[Builder]: return Builder + @property + def patrol_duration(self) -> timedelta: + # TODO: Only consider aircraft that can refuel with this tanker type. + refuel_time_minutes = 5 + for self.flight in self.package.flights: + flight_size = self.flight.roster.max_size + refuel_time_minutes = refuel_time_minutes + 4 * flight_size + 1 + + return timedelta(minutes=refuel_time_minutes) + def target_area_waypoint(self) -> FlightWaypoint: return FlightWaypoint( "TARGET AREA", diff --git a/game/ato/flightplans/patrolling.py b/game/ato/flightplans/patrolling.py index f8928834..43645f24 100644 --- a/game/ato/flightplans/patrolling.py +++ b/game/ato/flightplans/patrolling.py @@ -1,53 +1,63 @@ from __future__ import annotations -from abc import ABC +from abc import ABC, abstractmethod from collections.abc import Iterator +from dataclasses import dataclass from datetime import timedelta -from typing import TYPE_CHECKING, TypeGuard +from typing import Any, TYPE_CHECKING, TypeGuard, TypeVar -from game.ato.flightplans.standard import StandardFlightPlan +from game.ato.flightplans.standard import StandardFlightPlan, StandardLayout from game.typeguard import self_type_guard from game.utils import Distance, Speed if TYPE_CHECKING: - from ..flight import Flight from ..flightwaypoint import FlightWaypoint from .flightplan import FlightPlan -class PatrollingFlightPlan(StandardFlightPlan, ABC): - def __init__( - self, - flight: Flight, - departure: FlightWaypoint, - arrival: FlightWaypoint, - divert: FlightWaypoint | None, - bullseye: FlightWaypoint, - nav_to: list[FlightWaypoint], - nav_from: list[FlightWaypoint], - patrol_start: FlightWaypoint, - patrol_end: FlightWaypoint, - patrol_duration: timedelta, - patrol_speed: Speed, - engagement_distance: Distance, - ) -> None: - super().__init__(flight, departure, arrival, divert, bullseye) - self.nav_to = nav_to - self.nav_from = nav_from - self.patrol_start = patrol_start - self.patrol_end = patrol_end +@dataclass(frozen=True) +class PatrollingLayout(StandardLayout): + nav_to: list[FlightWaypoint] + patrol_start: FlightWaypoint + patrol_end: FlightWaypoint + nav_from: list[FlightWaypoint] - # Maximum time to remain on station. - self.patrol_duration = patrol_duration + def iter_waypoints(self) -> Iterator[FlightWaypoint]: + yield self.departure + yield from self.nav_to + yield self.patrol_start + yield self.patrol_end + yield from self.nav_from + yield self.arrival + if self.divert is not None: + yield self.divert + yield self.bullseye - # Racetrack speed TAS. - self.patrol_speed = patrol_speed - # The engagement range of any Search Then Engage task, or the radius of a - # Search Then Engage in Zone task. Any enemies of the appropriate type for - # this mission within this range of the flight's current position (or the - # center of the zone) will be engaged by the flight. - self.engagement_distance = engagement_distance +LayoutT = TypeVar("LayoutT", bound=PatrollingLayout) + + +class PatrollingFlightPlan(StandardFlightPlan[LayoutT], ABC): + @property + @abstractmethod + def patrol_duration(self) -> timedelta: + """Maximum time to remain on station.""" + + @property + @abstractmethod + def patrol_speed(self) -> Speed: + """Racetrack speed TAS.""" + + @property + @abstractmethod + def engagement_distance(self) -> Distance: + """The maximum engagement distance. + + The engagement range of any Search Then Engage task, or the radius of a Search + Then Engage in Zone task. Any enemies of the appropriate type for this mission + within this range of the flight's current position (or the center of the zone) + will be engaged by the flight. + """ @property def patrol_start_time(self) -> timedelta: @@ -61,38 +71,29 @@ class PatrollingFlightPlan(StandardFlightPlan, ABC): return self.patrol_start_time + self.patrol_duration def tot_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None: - if waypoint == self.patrol_start: + if waypoint == self.layout.patrol_start: return self.patrol_start_time return None def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None: - if waypoint == self.patrol_end: + if waypoint == self.layout.patrol_end: return self.patrol_end_time return None - def iter_waypoints(self) -> Iterator[FlightWaypoint]: - yield self.departure - yield from self.nav_to - yield self.patrol_start - yield self.patrol_end - yield from self.nav_from - yield self.arrival - if self.divert is not None: - yield self.divert - yield self.bullseye - @property def package_speed_waypoints(self) -> set[FlightWaypoint]: - return {self.patrol_start, self.patrol_end} + return {self.layout.patrol_start, self.layout.patrol_end} @property def tot_waypoint(self) -> FlightWaypoint | None: - return self.patrol_start + return self.layout.patrol_start @property def mission_departure_time(self) -> timedelta: return self.patrol_end_time @self_type_guard - def is_patrol(self, flight_plan: FlightPlan) -> TypeGuard[PatrollingFlightPlan]: + def is_patrol( + self, flight_plan: FlightPlan[Any] + ) -> TypeGuard[PatrollingFlightPlan[Any]]: return True diff --git a/game/ato/flightplans/rtb.py b/game/ato/flightplans/rtb.py index 8f84a9bc..b8806c53 100644 --- a/game/ato/flightplans/rtb.py +++ b/game/ato/flightplans/rtb.py @@ -1,22 +1,22 @@ from __future__ import annotations from collections.abc import Iterator +from dataclasses import dataclass from datetime import timedelta from typing import TYPE_CHECKING, Type from game.utils import feet from .ibuilder import IBuilder -from .standard import StandardFlightPlan +from .standard import StandardFlightPlan, StandardLayout from .waypointbuilder import WaypointBuilder from ..flightstate import InFlight if TYPE_CHECKING: - from ..flight import Flight from ..flightwaypoint import FlightWaypoint class Builder(IBuilder): - def build(self) -> RtbFlightPlan: + def build(self) -> RtbLayout: if not isinstance(self.flight.state, InFlight): raise RuntimeError(f"Cannot abort {self} because it is not in flight") @@ -36,8 +36,7 @@ class Builder(IBuilder): abort_point.name = "ABORT AND RTB" abort_point.pretty_name = "Abort and RTB" abort_point.description = "Abort mission and return to base" - return RtbFlightPlan( - flight=self.flight, + return RtbLayout( departure=builder.takeoff(self.flight.departure), abort_location=abort_point, nav_to_destination=builder.nav_path( @@ -52,24 +51,10 @@ class Builder(IBuilder): ) -class RtbFlightPlan(StandardFlightPlan): - def __init__( - self, - flight: Flight, - departure: FlightWaypoint, - arrival: FlightWaypoint, - divert: FlightWaypoint | None, - bullseye: FlightWaypoint, - abort_location: FlightWaypoint, - nav_to_destination: list[FlightWaypoint], - ) -> None: - super().__init__(flight, departure, arrival, divert, bullseye) - self.abort_location = abort_location - self.nav_to_destination = nav_to_destination - - @staticmethod - def builder_type() -> Type[Builder]: - return Builder +@dataclass(frozen=True) +class RtbLayout(StandardLayout): + abort_location: FlightWaypoint + nav_to_destination: list[FlightWaypoint] def iter_waypoints(self) -> Iterator[FlightWaypoint]: yield self.departure @@ -80,6 +65,12 @@ class RtbFlightPlan(StandardFlightPlan): yield self.divert yield self.bullseye + +class RtbFlightPlan(StandardFlightPlan[RtbLayout]): + @staticmethod + def builder_type() -> Type[Builder]: + return Builder + @property def abort_index(self) -> int: return 1 diff --git a/game/ato/flightplans/sead.py b/game/ato/flightplans/sead.py index 5b3157f9..b82b59f4 100644 --- a/game/ato/flightplans/sead.py +++ b/game/ato/flightplans/sead.py @@ -3,20 +3,28 @@ from __future__ import annotations from datetime import timedelta from typing import Type -from .formationattack import FormationAttackBuilder, FormationAttackFlightPlan +from .formationattack import ( + FormationAttackBuilder, + FormationAttackFlightPlan, + FormationAttackLayout, +) +from .. import Flight from ..flightwaypointtype import FlightWaypointType class SeadFlightPlan(FormationAttackFlightPlan): + def __init__(self, flight: Flight, layout: FormationAttackLayout) -> None: + super().__init__(flight, layout) + @staticmethod def builder_type() -> Type[Builder]: return Builder + @property + def lead_time(self) -> timedelta: + return timedelta(minutes=1) -class Builder(FormationAttackBuilder[SeadFlightPlan]): - def build(self) -> FormationAttackFlightPlan: - return self._build( - SeadFlightPlan, - FlightWaypointType.INGRESS_SEAD, - lead_time=timedelta(minutes=1), - ) + +class Builder(FormationAttackBuilder): + def build(self) -> FormationAttackLayout: + return self._build(FlightWaypointType.INGRESS_SEAD) diff --git a/game/ato/flightplans/standard.py b/game/ato/flightplans/standard.py index 7277271f..6fe1fa1a 100644 --- a/game/ato/flightplans/standard.py +++ b/game/ato/flightplans/standard.py @@ -1,33 +1,30 @@ from __future__ import annotations from abc import ABC -from typing import TYPE_CHECKING +from dataclasses import dataclass +from typing import TYPE_CHECKING, TypeVar -from game.ato.flightplans.flightplan import FlightPlan +from game.ato.flightplans.flightplan import FlightPlan, Layout if TYPE_CHECKING: - from ..flight import Flight from ..flightwaypoint import FlightWaypoint -class StandardFlightPlan(FlightPlan, ABC): +@dataclass(frozen=True) +class StandardLayout(Layout, ABC): + departure: FlightWaypoint + arrival: FlightWaypoint + divert: FlightWaypoint | None + bullseye: FlightWaypoint + + +LayoutT = TypeVar("LayoutT", bound=StandardLayout) + + +class StandardFlightPlan(FlightPlan[LayoutT], ABC): """Base type for all non-custom flight plans. We can't reason about custom flight plans so they get special treatment, but all others are guaranteed to have certain properties like departure and arrival points, potentially a divert field, and a bullseye """ - - def __init__( - self, - flight: Flight, - departure: FlightWaypoint, - arrival: FlightWaypoint, - divert: FlightWaypoint | None, - bullseye: FlightWaypoint, - ) -> None: - super().__init__(flight) - self.departure = departure - self.arrival = arrival - self.divert = divert - self.bullseye = bullseye diff --git a/game/ato/flightplans/strike.py b/game/ato/flightplans/strike.py index 04d608ac..145917d7 100644 --- a/game/ato/flightplans/strike.py +++ b/game/ato/flightplans/strike.py @@ -3,7 +3,11 @@ from __future__ import annotations from typing import Type from game.theater import TheaterGroundObject -from .formationattack import FormationAttackBuilder, FormationAttackFlightPlan +from .formationattack import ( + FormationAttackBuilder, + FormationAttackFlightPlan, + FormationAttackLayout, +) from .invalidobjectivelocation import InvalidObjectiveLocation from .waypointbuilder import StrikeTarget from ..flightwaypointtype import FlightWaypointType @@ -15,8 +19,8 @@ class StrikeFlightPlan(FormationAttackFlightPlan): return Builder -class Builder(FormationAttackBuilder[StrikeFlightPlan]): - def build(self) -> FormationAttackFlightPlan: +class Builder(FormationAttackBuilder): + def build(self) -> FormationAttackLayout: location = self.package.target if not isinstance(location, TheaterGroundObject): @@ -26,4 +30,4 @@ class Builder(FormationAttackBuilder[StrikeFlightPlan]): for idx, unit in enumerate(location.strike_targets): targets.append(StrikeTarget(f"{unit.type.id} #{idx}", unit)) - return self._build(StrikeFlightPlan, FlightWaypointType.INGRESS_STRIKE, targets) + return self._build(FlightWaypointType.INGRESS_STRIKE, targets) diff --git a/game/ato/flightplans/sweep.py b/game/ato/flightplans/sweep.py index e502d94a..7c351968 100644 --- a/game/ato/flightplans/sweep.py +++ b/game/ato/flightplans/sweep.py @@ -1,5 +1,6 @@ from __future__ import annotations +from dataclasses import dataclass from datetime import timedelta from typing import Iterator, TYPE_CHECKING, Type @@ -7,18 +8,17 @@ from dcs import Point from game.utils import Heading from .ibuilder import IBuilder -from .loiter import LoiterFlightPlan +from .loiter import LoiterFlightPlan, LoiterLayout from .waypointbuilder import WaypointBuilder from ..traveltime import GroundSpeed, TravelTime from ...flightplan import HoldZoneGeometry if TYPE_CHECKING: - from ..flight import Flight from ..flightwaypoint import FlightWaypoint class Builder(IBuilder): - def build(self) -> SweepFlightPlan: + def build(self) -> SweepLayout: assert self.package.waypoints is not None target = self.package.target.position heading = Heading.from_degrees( @@ -38,12 +38,9 @@ class Builder(IBuilder): if self.package.waypoints is not None: refuel = builder.refuel(self.package.waypoints.refuel) - return SweepFlightPlan( - flight=self.flight, - lead_time=timedelta(minutes=5), + return SweepLayout( departure=builder.takeoff(self.flight.departure), hold=hold, - hold_duration=timedelta(minutes=5), nav_to=builder.nav_path( hold.position, start.position, self.doctrine.ingress_altitude ), @@ -71,42 +68,13 @@ class Builder(IBuilder): ).find_best_hold_point() -class SweepFlightPlan(LoiterFlightPlan): - def __init__( - self, - flight: Flight, - departure: FlightWaypoint, - arrival: FlightWaypoint, - divert: FlightWaypoint | None, - bullseye: FlightWaypoint, - nav_to: list[FlightWaypoint], - nav_from: list[FlightWaypoint], - hold: FlightWaypoint, - hold_duration: timedelta, - sweep_start: FlightWaypoint, - sweep_end: FlightWaypoint, - refuel: FlightWaypoint, - lead_time: timedelta, - ) -> None: - super().__init__( - flight, - departure, - arrival, - divert, - bullseye, - nav_to, - nav_from, - hold, - hold_duration, - ) - self.sweep_start = sweep_start - self.sweep_end = sweep_end - self.refuel = refuel - self.lead_time = lead_time - - @staticmethod - def builder_type() -> Type[Builder]: - return Builder +@dataclass(frozen=True) +class SweepLayout(LoiterLayout): + nav_to: list[FlightWaypoint] + sweep_start: FlightWaypoint + sweep_end: FlightWaypoint + refuel: FlightWaypoint | None + nav_from: list[FlightWaypoint] def iter_waypoints(self) -> Iterator[FlightWaypoint]: yield self.departure @@ -122,13 +90,23 @@ class SweepFlightPlan(LoiterFlightPlan): yield self.divert yield self.bullseye + +class SweepFlightPlan(LoiterFlightPlan): + @property + def lead_time(self) -> timedelta: + return timedelta(minutes=5) + + @staticmethod + def builder_type() -> Type[Builder]: + return Builder + @property def combat_speed_waypoints(self) -> set[FlightWaypoint]: - return {self.sweep_end} + return {self.layout.sweep_end} @property def tot_waypoint(self) -> FlightWaypoint | None: - return self.sweep_end + return self.layout.sweep_end @property def tot_offset(self) -> timedelta: @@ -137,7 +115,7 @@ class SweepFlightPlan(LoiterFlightPlan): @property def sweep_start_time(self) -> timedelta: travel_time = self.travel_time_between_waypoints( - self.sweep_start, self.sweep_end + self.layout.sweep_start, self.layout.sweep_end ) return self.sweep_end_time - travel_time @@ -146,23 +124,23 @@ class SweepFlightPlan(LoiterFlightPlan): return self.tot def tot_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None: - if waypoint == self.sweep_start: + if waypoint == self.layout.sweep_start: return self.sweep_start_time - if waypoint == self.sweep_end: + if waypoint == self.layout.sweep_end: return self.sweep_end_time return None def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None: - if waypoint == self.hold: + if waypoint == self.layout.hold: return self.push_time return None @property def push_time(self) -> timedelta: return self.sweep_end_time - TravelTime.between_points( - self.hold.position, - self.sweep_end.position, - GroundSpeed.for_flight(self.flight, self.hold.alt), + self.layout.hold.position, + self.layout.sweep_end.position, + GroundSpeed.for_flight(self.flight, self.layout.hold.alt), ) def mission_departure_time(self) -> timedelta: diff --git a/game/ato/flightplans/tarcap.py b/game/ato/flightplans/tarcap.py index cc7bca33..6ec4c1b5 100644 --- a/game/ato/flightplans/tarcap.py +++ b/game/ato/flightplans/tarcap.py @@ -2,21 +2,21 @@ from __future__ import annotations import random from collections.abc import Iterator +from dataclasses import dataclass from datetime import timedelta from typing import TYPE_CHECKING, Type from game.utils import Distance, Speed, feet from .capbuilder import CapBuilder -from .patrolling import PatrollingFlightPlan +from .patrolling import PatrollingFlightPlan, PatrollingLayout from .waypointbuilder import WaypointBuilder if TYPE_CHECKING: - from ..flight import Flight from ..flightwaypoint import FlightWaypoint class Builder(CapBuilder): - def build(self) -> TarCapFlightPlan: + def build(self) -> TarCapLayout: location = self.package.target preferred_alt = self.flight.unit_type.preferred_patrol_altitude @@ -25,7 +25,6 @@ class Builder(CapBuilder): self.doctrine.min_patrol_altitude, min(self.doctrine.max_patrol_altitude, randomized_alt), ) - patrol_speed = self.flight.unit_type.preferred_patrol_speed(patrol_alt) builder = WaypointBuilder(self.flight, self.coalition) orbit0p, orbit1p = self.cap_racetrack_for_objective(location, barcap=False) @@ -37,16 +36,7 @@ class Builder(CapBuilder): if self.package.waypoints is not None: refuel = builder.refuel(self.package.waypoints.refuel) - return TarCapFlightPlan( - flight=self.flight, - lead_time=timedelta(minutes=2), - # Note that this duration only has an effect if there are no - # flights in the package that have requested escort. If the package - # requests an escort the CAP self.flight will remain on station for the - # duration of the escorted mission, or until it is winchester/bingo. - patrol_duration=self.doctrine.cap_duration, - patrol_speed=patrol_speed, - engagement_distance=self.doctrine.cap_engagement_range, + return TarCapLayout( departure=builder.takeoff(self.flight.departure), nav_to=builder.nav_path( self.flight.departure.position, orbit0p, patrol_alt @@ -63,44 +53,9 @@ class Builder(CapBuilder): ) -class TarCapFlightPlan(PatrollingFlightPlan): - def __init__( - self, - flight: Flight, - departure: FlightWaypoint, - arrival: FlightWaypoint, - divert: FlightWaypoint | None, - bullseye: FlightWaypoint, - nav_to: list[FlightWaypoint], - nav_from: list[FlightWaypoint], - patrol_start: FlightWaypoint, - patrol_end: FlightWaypoint, - patrol_duration: timedelta, - patrol_speed: Speed, - engagement_distance: Distance, - refuel: FlightWaypoint | None, - lead_time: timedelta, - ) -> None: - super().__init__( - flight, - departure, - arrival, - divert, - bullseye, - nav_to, - nav_from, - patrol_start, - patrol_end, - patrol_duration, - patrol_speed, - engagement_distance, - ) - self.refuel = refuel - self.lead_time = lead_time - - @staticmethod - def builder_type() -> Type[Builder]: - return Builder +@dataclass(frozen=True) +class TarCapLayout(PatrollingLayout): + refuel: FlightWaypoint | None def iter_waypoints(self) -> Iterator[FlightWaypoint]: yield self.departure @@ -115,16 +70,44 @@ class TarCapFlightPlan(PatrollingFlightPlan): yield self.divert yield self.bullseye + +class TarCapFlightPlan(PatrollingFlightPlan[TarCapLayout]): + @property + def lead_time(self) -> timedelta: + return timedelta(minutes=2) + + @property + def patrol_duration(self) -> timedelta: + # Note that this duration only has an effect if there are no + # flights in the package that have requested escort. If the package + # requests an escort the CAP self.flight will remain on station for the + # duration of the escorted mission, or until it is winchester/bingo. + 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 + + @staticmethod + def builder_type() -> Type[Builder]: + return Builder + @property def combat_speed_waypoints(self) -> set[FlightWaypoint]: - return {self.patrol_start, self.patrol_end} + return {self.layout.patrol_start, self.layout.patrol_end} @property def tot_offset(self) -> timedelta: return -self.lead_time def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None: - if waypoint == self.patrol_end: + if waypoint == self.layout.patrol_end: return self.patrol_end_time return super().depart_time_for_waypoint(waypoint) diff --git a/game/ato/flightplans/theaterrefueling.py b/game/ato/flightplans/theaterrefueling.py index b9cb0e51..f40d3cf5 100644 --- a/game/ato/flightplans/theaterrefueling.py +++ b/game/ato/flightplans/theaterrefueling.py @@ -3,18 +3,16 @@ from __future__ import annotations from datetime import timedelta from typing import Type -from game.utils import Heading, feet, knots, meters, nautical_miles +from game.utils import Distance, Heading, Speed, feet, knots, meters, nautical_miles from .ibuilder import IBuilder -from .patrolling import PatrollingFlightPlan +from .patrolling import PatrollingFlightPlan, PatrollingLayout from .waypointbuilder import WaypointBuilder class Builder(IBuilder): - def build(self) -> TheaterRefuelingFlightPlan: + def build(self) -> PatrollingLayout: racetrack_half_distance = nautical_miles(20).meters - patrol_duration = timedelta(hours=1) - location = self.package.target closest_boundary = self.threat_zones.closest_boundary(location.position) @@ -53,17 +51,9 @@ class Builder(IBuilder): else: altitude = feet(21000) - # TODO: Could use self.flight.unit_type.preferred_patrol_speed(altitude). - if tanker_type.patrol_speed is not None: - speed = tanker_type.patrol_speed - else: - # ~280 knots IAS at 21000. - speed = knots(400) - racetrack = builder.race_track(racetrack_start, racetrack_end, altitude) - return TheaterRefuelingFlightPlan( - flight=self.flight, + return PatrollingLayout( departure=builder.takeoff(self.flight.departure), nav_to=builder.nav_path( self.flight.departure.position, racetrack_start, altitude @@ -76,15 +66,28 @@ class Builder(IBuilder): arrival=builder.land(self.flight.arrival), divert=builder.divert(self.flight.divert), bullseye=builder.bullseye(), - patrol_duration=patrol_duration, - patrol_speed=speed, - # TODO: Factor out a common base of the combat and non-combat race-tracks. - # No harm in setting this, but we ought to clean up a bit. - engagement_distance=meters(0), ) -class TheaterRefuelingFlightPlan(PatrollingFlightPlan): +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) diff --git a/game/missiongenerator/aircraft/waypoints/casingress.py b/game/missiongenerator/aircraft/waypoints/casingress.py index 55a25ebc..d63b20dc 100644 --- a/game/missiongenerator/aircraft/waypoints/casingress.py +++ b/game/missiongenerator/aircraft/waypoints/casingress.py @@ -13,7 +13,7 @@ class CasIngressBuilder(PydcsWaypointBuilder): if isinstance(self.flight.flight_plan, CasFlightPlan): waypoint.add_task( EngageTargetsInZone( - position=self.flight.flight_plan.target.position, + position=self.flight.flight_plan.layout.target.position, radius=int(self.flight.flight_plan.engagement_distance.meters), targets=[ Targets.All.GroundUnits.GroundVehicles, diff --git a/game/server/flights/routes.py b/game/server/flights/routes.py index 4ec0684f..886e3fa0 100644 --- a/game/server/flights/routes.py +++ b/game/server/flights/routes.py @@ -4,8 +4,8 @@ from fastapi import APIRouter, Depends from shapely.geometry import LineString, Point as ShapelyPoint from game import Game -from game.ato.flightplans.patrolling import PatrollingFlightPlan from game.ato.flightplans.cas import CasFlightPlan +from game.ato.flightplans.patrolling import PatrollingFlightPlan from game.server import GameContext from game.server.flights.models import FlightJs from game.server.leaflet import LeafletPoly, ShapelyUtil @@ -41,10 +41,10 @@ def commit_boundary( flight = game.db.flights.get(flight_id) if not isinstance(flight.flight_plan, PatrollingFlightPlan): return [] - start = flight.flight_plan.patrol_start - end = flight.flight_plan.patrol_end + start = flight.flight_plan.layout.patrol_start + end = flight.flight_plan.layout.patrol_end if isinstance(flight.flight_plan, CasFlightPlan): - center = flight.flight_plan.target.position + center = flight.flight_plan.layout.target.position commit_center = ShapelyPoint(center.x, center.y) else: commit_center = LineString(