diff --git a/doc/design/flight-creation.md b/doc/design/flight-creation.md new file mode 100644 index 00000000..e69de29b diff --git a/game/ato/flight.py b/game/ato/flight.py index 71f77534..243b4a6d 100644 --- a/game/ato/flight.py +++ b/game/ato/flight.py @@ -7,10 +7,12 @@ from typing import Any, List, Optional, TYPE_CHECKING from dcs import Point from dcs.planes import C_101CC, C_101EB, Su_33 +from .flightplans.planningerror import PlanningError from .flightroster import FlightRoster from .flightstate import FlightState, Navigating, Uninitialized from .flightstate.killed import Killed from .loadouts import Loadout +from .packagewaypoints import PackageWaypoints from ..sidc import ( Entity, SidcDescribable, @@ -81,9 +83,9 @@ class Flight(SidcDescribable): # Used for simulating the travel to first contact. self.state: FlightState = Uninitialized(self, squadron.settings) - # Will be replaced with a more appropriate FlightPlan by - # FlightPlanBuilder, but an empty flight plan the flight begins with an - # empty flight plan. + # Will be replaced with a more appropriate FlightPlan later, but start with a + # cheaply constructed one since adding more flights to the package may affect + # the optimal layout. from .flightplans.custom import CustomFlightPlan, CustomLayout self.flight_plan: FlightPlan[Any] = CustomFlightPlan( @@ -194,8 +196,7 @@ 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).build() self.set_state( Navigating( @@ -244,3 +245,24 @@ class Flight(SidcDescribable): for pilot in self.roster.pilots: if pilot is not None: results.kill_pilot(self, pilot) + + def recreate_flight_plan(self) -> None: + self.flight_plan = self._make_flight_plan() + + def _make_flight_plan(self) -> FlightPlan[Any]: + from game.navmesh import NavMeshError + from .flightplans.flightplanbuildertypes import FlightPlanBuilderTypes + + try: + if self.package.waypoints is None: + self.package.waypoints = PackageWaypoints.create( + self.package, self.coalition + ) + builder = FlightPlanBuilderTypes.for_flight(self)(self) + return builder.build() + except NavMeshError as ex: + color = "blue" if self.squadron.player else "red" + raise PlanningError( + f"Could not plan {color} {self.flight_type.value} from " + f"{self.departure} to {self.package.target}" + ) from ex 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..8d4fa0f2 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 @@ -12,12 +13,57 @@ from .ibuilder import IBuilder from .waypointbuilder import WaypointBuilder if TYPE_CHECKING: - from ..flight import Flight 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]): + @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 +124,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..41ee1254 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 @@ -13,12 +13,58 @@ 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) -> 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]): + @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 +143,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 deleted file mode 100644 index c8028239..00000000 --- a/game/ato/flightplans/flightplanbuilder.py +++ /dev/null @@ -1,196 +0,0 @@ -from __future__ import annotations - -from typing import Any, TYPE_CHECKING, Type - -from game.ato import FlightType -from game.ato.closestairfields import ObjectiveDistanceCache -from game.data.doctrine import Doctrine -from game.flightplan import IpZoneGeometry, JoinZoneGeometry -from game.flightplan.refuelzonegeometry import RefuelZoneGeometry -from .aewc import AewcFlightPlan -from .airassault import AirAssaultFlightPlan -from .airlift import AirliftFlightPlan -from .antiship import AntiShipFlightPlan -from .bai import BaiFlightPlan -from .barcap import BarCapFlightPlan -from .cas import CasFlightPlan -from .dead import DeadFlightPlan -from .escort import EscortFlightPlan -from .ferry import FerryFlightPlan -from .flightplan import FlightPlan -from .ocaaircraft import OcaAircraftFlightPlan -from .ocarunway import OcaRunwayFlightPlan -from .packagerefueling import PackageRefuelingFlightPlan -from .planningerror import PlanningError -from .sead import SeadFlightPlan -from .strike import StrikeFlightPlan -from .sweep import SweepFlightPlan -from .tarcap import TarCapFlightPlan -from .theaterrefueling import TheaterRefuelingFlightPlan -from .waypointbuilder import WaypointBuilder - -if TYPE_CHECKING: - from game.ato import Flight, FlightWaypoint, Package - from game.coalition import Coalition - from game.theater import ConflictTheater, ControlPoint, FrontLine - from game.threatzones import ThreatZones - - -class FlightPlanBuilder: - """Generates flight plans for flights.""" - - def __init__( - self, package: Package, coalition: Coalition, theater: ConflictTheater - ) -> None: - # TODO: Plan similar altitudes for the in-country leg of the mission. - # Waypoint altitudes for a given flight *shouldn't* differ too much - # between the join and split points, so we don't need speeds for each - # leg individually since they should all be fairly similar. This doesn't - # hold too well right now since nothing is stopping each waypoint from - # jumping 20k feet each time, but that's a huge waste of energy we - # should be avoiding anyway. - self.package = package - self.coalition = coalition - self.theater = theater - - @property - def is_player(self) -> bool: - return self.coalition.player - - @property - def doctrine(self) -> Doctrine: - return self.coalition.doctrine - - @property - def threat_zones(self) -> ThreatZones: - return self.coalition.opponent.threat_zone - - def populate_flight_plan(self, flight: Flight) -> None: - """Creates a default flight plan for the given mission.""" - if flight not in self.package.flights: - raise RuntimeError("Flight must be a part of the package") - - from game.navmesh import NavMeshError - - try: - if self.package.waypoints is None: - self.regenerate_package_waypoints() - flight.flight_plan = self.generate_flight_plan(flight) - except NavMeshError as ex: - color = "blue" if self.is_player else "red" - raise PlanningError( - f"Could not plan {color} {flight.flight_type.value} from " - f"{flight.departure} to {flight.package.target}" - ) from ex - - def plan_type(self, task: FlightType) -> Type[FlightPlan[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 - ): - return TheaterRefuelingFlightPlan - return PackageRefuelingFlightPlan - - 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, - } - return plan_dict.get(task) - - 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" - ) - layout = plan_type.builder_type()(flight, self.theater).build() - return plan_type(flight, layout) - - def regenerate_flight_plans(self) -> None: - new_flights: list[Flight] = [] - for old_flight in self.package.flights: - old_flight.flight_plan = self.generate_flight_plan(old_flight) - new_flights.append(old_flight) - self.package.flights = new_flights - - def regenerate_package_waypoints(self) -> None: - from game.ato.packagewaypoints import PackageWaypoints - - package_airfield = self.package_airfield() - - # Start by picking the best IP for the attack. - ingress_point = IpZoneGeometry( - self.package.target.position, - package_airfield.position, - self.coalition, - ).find_best_ip() - - join_point = JoinZoneGeometry( - self.package.target.position, - package_airfield.position, - ingress_point, - self.coalition, - ).find_best_join_point() - - refuel_point = RefuelZoneGeometry( - package_airfield.position, - join_point, - self.coalition, - ).find_best_refuel_point() - - # And the split point based on the best route from the IP. Since that's no - # different than the best route *to* the IP, this is the same as the join point. - # TODO: Estimate attack completion point based on the IP and split from there? - self.package.waypoints = PackageWaypoints( - WaypointBuilder.perturb(join_point), - ingress_point, - WaypointBuilder.perturb(join_point), - refuel_point, - ) - - # TODO: Make a model for the waypoint builder and use that in the UI. - def generate_rtb_waypoint( - self, flight: Flight, arrival: ControlPoint - ) -> FlightWaypoint: - """Generate RTB landing point. - - Args: - flight: The flight to generate the landing waypoint for. - arrival: Arrival airfield or carrier. - """ - builder = WaypointBuilder(flight, self.coalition) - return builder.land(arrival) - - def package_airfield(self) -> ControlPoint: - # We'll always have a package, but if this is being planned via the UI - # it could be the first flight in the package. - if not self.package.flights: - raise PlanningError( - "Cannot determine source airfield for package with no flights" - ) - - # The package airfield is either the flight's airfield (when there is no - # package) or the closest airfield to the objective that is the - # departure airfield for some flight in the package. - cache = ObjectiveDistanceCache.get_closest_airfields(self.package.target) - for airfield in cache.operational_airfields: - for flight in self.package.flights: - if flight.departure == airfield: - return airfield - raise PlanningError("Could not find any airfield assigned to this package") diff --git a/game/ato/flightplans/flightplanbuildertypes.py b/game/ato/flightplans/flightplanbuildertypes.py new file mode 100644 index 00000000..5d367214 --- /dev/null +++ b/game/ato/flightplans/flightplanbuildertypes.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from typing import Any, TYPE_CHECKING, Type + +from game.ato import FlightType +from .aewc import AewcFlightPlan +from .airassault import AirAssaultFlightPlan +from .airlift import AirliftFlightPlan +from .antiship import AntiShipFlightPlan +from .bai import BaiFlightPlan +from .barcap import BarCapFlightPlan +from .cas import CasFlightPlan +from .dead import DeadFlightPlan +from .escort import EscortFlightPlan +from .ferry import FerryFlightPlan +from .ibuilder import IBuilder +from .ocaaircraft import OcaAircraftFlightPlan +from .ocarunway import OcaRunwayFlightPlan +from .packagerefueling import PackageRefuelingFlightPlan +from .planningerror import PlanningError +from .sead import SeadFlightPlan +from .strike import StrikeFlightPlan +from .sweep import SweepFlightPlan +from .tarcap import TarCapFlightPlan +from .theaterrefueling import TheaterRefuelingFlightPlan + +if TYPE_CHECKING: + from game.ato import Flight + from game.theater import FrontLine + + +class FlightPlanBuilderTypes: + @staticmethod + def for_flight(flight: Flight) -> Type[IBuilder[Any, Any]]: + if flight.flight_type is FlightType.REFUELING: + if flight.package.target.is_friendly(flight.squadron.player) or isinstance( + flight.package.target, FrontLine + ): + return TheaterRefuelingFlightPlan.builder_type() + return PackageRefuelingFlightPlan.builder_type() + + 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 builder_dict[flight.flight_type] + except KeyError as ex: + raise PlanningError( + f"{flight.flight_type} flight plan generation not implemented" + ) from ex 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..714b4862 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,26 @@ if TYPE_CHECKING: from game.threatzones import ThreatZones from ..flight import Flight from ..package import Package - from .flightplan import Layout -class IBuilder(ABC): - def __init__(self, flight: Flight, theater: ConflictTheater) -> None: +FlightPlanT = TypeVar("FlightPlanT", bound=FlightPlan[Any]) +LayoutT = TypeVar("LayoutT", bound=Layout) + + +class IBuilder(ABC, Generic[FlightPlanT, LayoutT]): + def __init__(self, flight: Flight) -> None: self.flight = flight - self.theater = theater + + @property + def theater(self) -> ConflictTheater: + return self.flight.departure.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..e58616aa 100644 --- a/game/ato/flightplans/sead.py +++ b/game/ato/flightplans/sead.py @@ -8,14 +8,10 @@ from .formationattack import ( 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 @@ -25,6 +21,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()) diff --git a/game/ato/package.py b/game/ato/package.py index b36cd37b..ca27a22a 100644 --- a/game/ato/package.py +++ b/game/ato/package.py @@ -6,16 +6,17 @@ from dataclasses import dataclass, field from datetime import timedelta from typing import Dict, List, Optional, TYPE_CHECKING -from .flightplans.formation import FormationFlightPlan from game.db import Database from game.utils import Speed +from .closestairfields import ObjectiveDistanceCache from .flight import Flight +from .flightplans.formation import FormationFlightPlan from .flighttype import FlightType from .packagewaypoints import PackageWaypoints from .traveltime import TotEstimator if TYPE_CHECKING: - from game.theater import MissionTarget + from game.theater import ControlPoint, MissionTarget @dataclass @@ -31,8 +32,6 @@ class Package: #: The set of flights in the package. flights: List[Flight] = field(default_factory=list) - delay: int = field(default=0) - #: True if the package ToT should be reset to ASAP whenever the player makes #: a change. This is really a UI property rather than a game property, but #: we want it to persist in the save. @@ -193,6 +192,24 @@ class Package: return "OCA Strike" return str(task) + def departure_closest_to_target(self) -> ControlPoint: + # We'll always have a package, but if this is being planned via the UI + # it could be the first flight in the package. + if not self.flights: + raise RuntimeError( + "Cannot determine source airfield for package with no flights" + ) + + # The package airfield is either the flight's airfield (when there is no + # package) or the closest airfield to the objective that is the + # departure airfield for some flight in the package. + cache = ObjectiveDistanceCache.get_closest_airfields(self.target) + for airfield in cache.operational_airfields: + for flight in self.flights: + if flight.departure == airfield: + return airfield + raise RuntimeError("Could not find any airfield assigned to this package") + def __hash__(self) -> int: # TODO: Far from perfect. Number packages? return hash(self.target.name) diff --git a/game/ato/packagewaypoints.py b/game/ato/packagewaypoints.py index e478b607..62bd4219 100644 --- a/game/ato/packagewaypoints.py +++ b/game/ato/packagewaypoints.py @@ -1,8 +1,18 @@ +from __future__ import annotations + from dataclasses import dataclass -from typing import Optional +from typing import TYPE_CHECKING from dcs import Point +from game.ato.flightplans.waypointbuilder import WaypointBuilder +from game.flightplan import IpZoneGeometry, JoinZoneGeometry +from game.flightplan.refuelzonegeometry import RefuelZoneGeometry + +if TYPE_CHECKING: + from game.ato import Package + from game.coalition import Coalition + @dataclass(frozen=True) class PackageWaypoints: @@ -10,3 +20,37 @@ class PackageWaypoints: ingress: Point split: Point refuel: Point + + @staticmethod + def create(package: Package, coalition: Coalition) -> PackageWaypoints: + origin = package.departure_closest_to_target() + + # Start by picking the best IP for the attack. + ingress_point = IpZoneGeometry( + package.target.position, + origin.position, + coalition, + ).find_best_ip() + + join_point = JoinZoneGeometry( + package.target.position, + origin.position, + ingress_point, + coalition, + ).find_best_join_point() + + refuel_point = RefuelZoneGeometry( + origin.position, + join_point, + coalition, + ).find_best_refuel_point() + + # And the split point based on the best route from the IP. Since that's no + # different than the best route *to* the IP, this is the same as the join point. + # TODO: Estimate attack completion point based on the IP and split from there? + return PackageWaypoints( + WaypointBuilder.perturb(join_point), + ingress_point, + WaypointBuilder.perturb(join_point), + refuel_point, + ) diff --git a/game/campaignloader/mizcampaignloader.py b/game/campaignloader/mizcampaignloader.py index 25364916..46b44deb 100644 --- a/game/campaignloader/mizcampaignloader.py +++ b/game/campaignloader/mizcampaignloader.py @@ -107,9 +107,8 @@ class MizCampaignLoader: if self.mission.country(self.RED_COUNTRY.name) is None: self.mission.coalition["red"].add_country(self.RED_COUNTRY) - @staticmethod - def control_point_from_airport(airport: Airport) -> ControlPoint: - cp = Airfield(airport, starts_blue=airport.is_blue()) + def control_point_from_airport(self, airport: Airport) -> ControlPoint: + cp = Airfield(airport, self.theater, starts_blue=airport.is_blue()) # Use the unlimited aircraft option to determine if an airfield should # be owned by the player when the campaign is "inverted". @@ -252,20 +251,26 @@ class MizCampaignLoader: for blue in (False, True): for group in self.off_map_spawns(blue): control_point = OffMapSpawn( - str(group.name), group.position, starts_blue=blue + str(group.name), group.position, self.theater, starts_blue=blue ) control_point.captured_invert = group.late_activation control_points[control_point.id] = control_point for ship in self.carriers(blue): - control_point = Carrier(ship.name, ship.position, starts_blue=blue) + control_point = Carrier( + ship.name, ship.position, self.theater, starts_blue=blue + ) control_point.captured_invert = ship.late_activation control_points[control_point.id] = control_point for ship in self.lhas(blue): - control_point = Lha(ship.name, ship.position, starts_blue=blue) + control_point = Lha( + ship.name, ship.position, self.theater, starts_blue=blue + ) control_point.captured_invert = ship.late_activation control_points[control_point.id] = control_point for fob in self.fobs(blue): - control_point = Fob(str(fob.name), fob.position, starts_blue=blue) + control_point = Fob( + str(fob.name), fob.position, self.theater, starts_blue=blue + ) control_point.captured_invert = fob.late_activation control_points[control_point.id] = control_point diff --git a/game/commander/packagefulfiller.py b/game/commander/packagefulfiller.py index fc95ce0f..b2584c56 100644 --- a/game/commander/packagefulfiller.py +++ b/game/commander/packagefulfiller.py @@ -6,7 +6,6 @@ from typing import Dict, Iterable, Optional, Set, TYPE_CHECKING from game.ato.airtaaskingorder import AirTaskingOrder from game.ato.closestairfields import ObjectiveDistanceCache -from game.ato.flightplans.flightplanbuilder import FlightPlanBuilder from game.ato.flighttype import FlightType from game.ato.package import Package from game.commander.missionproposals import EscortType, ProposedFlight, ProposedMission @@ -191,12 +190,9 @@ class PackageFulfiller: # flights that will rendezvous with their package will be affected by # the other flights in the package. Escorts will not be able to # contribute to this. - flight_plan_builder = FlightPlanBuilder( - builder.package, self.coalition, self.theater - ) for flight in builder.package.flights: with tracer.trace("Flight plan population"): - flight_plan_builder.populate_flight_plan(flight) + flight.recreate_flight_plan() needed_escorts = self.check_needed_escorts(builder) for escort in escorts: @@ -222,7 +218,7 @@ class PackageFulfiller: for flight in package.flights: if not flight.flight_plan.waypoints: with tracer.trace("Flight plan population"): - flight_plan_builder.populate_flight_plan(flight) + flight.recreate_flight_plan() if package.has_players and self.player_missions_asap: package.auto_asap = True diff --git a/game/squadrons/squadron.py b/game/squadrons/squadron.py index c82566e0..9fdc42c0 100644 --- a/game/squadrons/squadron.py +++ b/game/squadrons/squadron.py @@ -11,7 +11,6 @@ from faker import Faker from game.ato import Flight, FlightType, Package from game.settings import AutoAtoBehavior, Settings from .pilot import Pilot, PilotStatus -from ..ato.flightplans.flightplanbuilder import FlightPlanBuilder from ..db.database import Database from ..utils import meters @@ -19,7 +18,7 @@ if TYPE_CHECKING: from game import Game from game.coalition import Coalition from game.dcs.aircrafttype import AircraftType - from game.theater import ControlPoint, ConflictTheater, MissionTarget + from game.theater import ControlPoint, MissionTarget from .operatingbases import OperatingBases from .squadrondef import SquadronDef @@ -335,9 +334,7 @@ class Squadron: def arrival(self) -> ControlPoint: return self.location if self.destination is None else self.destination - def plan_relocation( - self, destination: ControlPoint, theater: ConflictTheater - ) -> None: + def plan_relocation(self, destination: ControlPoint) -> None: if destination == self.location: logging.warning( f"Attempted to plan relocation of {self} to current location " @@ -356,7 +353,7 @@ class Squadron: if not destination.can_operate(self.aircraft): raise RuntimeError(f"{self} cannot operate at {destination}.") self.destination = destination - self.replan_ferry_flights(theater) + self.replan_ferry_flights() def cancel_relocation(self) -> None: if self.destination is None: @@ -371,9 +368,9 @@ class Squadron: self.destination = None self.cancel_ferry_flights() - def replan_ferry_flights(self, theater: ConflictTheater) -> None: + def replan_ferry_flights(self) -> None: self.cancel_ferry_flights() - self.plan_ferry_flights(theater) + self.plan_ferry_flights() def cancel_ferry_flights(self) -> None: for package in self.coalition.ato.packages: @@ -384,7 +381,7 @@ class Squadron: if not package.flights: self.coalition.ato.remove_package(package) - def plan_ferry_flights(self, theater: ConflictTheater) -> None: + def plan_ferry_flights(self) -> None: if self.destination is None: raise RuntimeError( f"Cannot plan ferry flights for {self} because there is no destination." @@ -394,17 +391,14 @@ class Squadron: return package = Package(self.destination, self.flight_db) - builder = FlightPlanBuilder(package, self.coalition, theater) while remaining: size = min(remaining, self.aircraft.max_group_size) - self.plan_ferry_flight(builder, package, size) + self.plan_ferry_flight(package, size) remaining -= size package.set_tot_asap() self.coalition.ato.add_package(package) - def plan_ferry_flight( - self, builder: FlightPlanBuilder, package: Package, size: int - ) -> None: + def plan_ferry_flight(self, package: Package, size: int) -> None: start_type = self.location.required_aircraft_start_type if start_type is None: start_type = self.settings.default_start_type @@ -419,7 +413,7 @@ class Squadron: divert=None, ) package.add_flight(flight) - builder.populate_flight_plan(flight) + flight.recreate_flight_plan() @classmethod def create_from( diff --git a/game/theater/controlpoint.py b/game/theater/controlpoint.py index b875226f..034bd165 100644 --- a/game/theater/controlpoint.py +++ b/game/theater/controlpoint.py @@ -26,21 +26,21 @@ from typing import ( from uuid import UUID from dcs.mapping import Point -from dcs.terrain.terrain import Airport, ParkingSlot -from dcs.unitgroup import ShipGroup, StaticGroup -from dcs.unittype import ShipType from dcs.ships import ( CVN_71, CVN_72, CVN_73, CVN_75, CV_1143_5, - KUZNECOW, - Stennis, Forrestal, + KUZNECOW, LHA_Tarawa, + Stennis, Type_071, ) +from dcs.terrain.terrain import Airport, ParkingSlot +from dcs.unitgroup import ShipGroup, StaticGroup +from dcs.unittype import ShipType from game.ato.closestairfields import ObjectiveDistanceCache from game.ground_forces.combat_stance import CombatStance @@ -56,8 +56,8 @@ from game.sidc import ( Status, SymbolSet, ) -from game.utils import Distance, Heading, meters from game.theater.presetlocation import PresetLocation +from game.utils import Distance, Heading, meters from .base import Base from .frontline import FrontLine from .missiontarget import MissionTarget @@ -320,6 +320,7 @@ class ControlPoint(MissionTarget, SidcDescribable, ABC): name: str, position: Point, at: StartingPosition, + theater: ConflictTheater, starts_blue: bool, cptype: ControlPointType = ControlPointType.AIRBASE, ) -> None: @@ -327,6 +328,7 @@ class ControlPoint(MissionTarget, SidcDescribable, ABC): self.id = uuid.uuid4() self.full_name = name self.at = at + self.theater = theater self.starts_blue = starts_blue self.connected_objectives: List[TheaterGroundObject] = [] self.preset_locations = PresetLocations() @@ -1039,11 +1041,14 @@ class ControlPoint(MissionTarget, SidcDescribable, ABC): class Airfield(ControlPoint): - def __init__(self, airport: Airport, starts_blue: bool) -> None: + def __init__( + self, airport: Airport, theater: ConflictTheater, starts_blue: bool + ) -> None: super().__init__( airport.name, airport.position, airport, + theater, starts_blue, cptype=ControlPointType.AIRBASE, ) @@ -1236,9 +1241,16 @@ class NavalControlPoint(ControlPoint, ABC): class Carrier(NavalControlPoint): - def __init__(self, name: str, at: Point, starts_blue: bool): + def __init__( + self, name: str, at: Point, theater: ConflictTheater, starts_blue: bool + ): super().__init__( - name, at, at, starts_blue, cptype=ControlPointType.AIRCRAFT_CARRIER_GROUP + name, + at, + at, + theater, + starts_blue, + cptype=ControlPointType.AIRCRAFT_CARRIER_GROUP, ) @property @@ -1275,8 +1287,12 @@ class Carrier(NavalControlPoint): class Lha(NavalControlPoint): - def __init__(self, name: str, at: Point, starts_blue: bool): - super().__init__(name, at, at, starts_blue, cptype=ControlPointType.LHA_GROUP) + def __init__( + self, name: str, at: Point, theater: ConflictTheater, starts_blue: bool + ): + super().__init__( + name, at, at, theater, starts_blue, cptype=ControlPointType.LHA_GROUP + ) @property def symbol_set_and_entity(self) -> tuple[SymbolSet, Entity]: @@ -1305,9 +1321,16 @@ class OffMapSpawn(ControlPoint): def runway_is_operational(self) -> bool: return True - def __init__(self, name: str, position: Point, starts_blue: bool): + def __init__( + self, name: str, position: Point, theater: ConflictTheater, starts_blue: bool + ): super().__init__( - name, position, position, starts_blue, cptype=ControlPointType.OFF_MAP + name, + position, + position, + theater, + starts_blue, + cptype=ControlPointType.OFF_MAP, ) @property @@ -1364,8 +1387,12 @@ class OffMapSpawn(ControlPoint): class Fob(ControlPoint): - def __init__(self, name: str, at: Point, starts_blue: bool): - super().__init__(name, at, at, starts_blue, cptype=ControlPointType.FOB) + def __init__( + self, name: str, at: Point, theater: ConflictTheater, starts_blue: bool + ): + super().__init__( + name, at, at, theater, starts_blue, cptype=ControlPointType.FOB + ) self.name = name @property diff --git a/game/transfers.py b/game/transfers.py index 07623e93..d6124b4a 100644 --- a/game/transfers.py +++ b/game/transfers.py @@ -43,7 +43,6 @@ from dcs.mapping import Point from game.ato.ai_flight_planner_db import aircraft_for_task from game.ato.closestairfields import ObjectiveDistanceCache from game.ato.flight import Flight -from game.ato.flightplans.flightplanbuilder import FlightPlanBuilder from game.ato.flighttype import FlightType from game.ato.package import Package from game.dcs.aircrafttype import AircraftType @@ -364,10 +363,7 @@ class AirliftPlanner: transfer.transport = transport self.package.add_flight(flight) - planner = FlightPlanBuilder( - self.package, self.game.coalition_for(self.for_player), self.game.theater - ) - planner.populate_flight_plan(flight) + flight.recreate_flight_plan() return flight_size diff --git a/qt_ui/windows/SquadronDialog.py b/qt_ui/windows/SquadronDialog.py index b9bf48ef..4465097a 100644 --- a/qt_ui/windows/SquadronDialog.py +++ b/qt_ui/windows/SquadronDialog.py @@ -1,30 +1,25 @@ import logging from typing import Callable, Iterator, Optional -from PySide2.QtCore import ( - QItemSelectionModel, - QModelIndex, - Qt, - QItemSelection, -) +from PySide2.QtCore import QItemSelection, QItemSelectionModel, QModelIndex, Qt from PySide2.QtWidgets import ( QAbstractItemView, - QDialog, - QListView, - QVBoxLayout, - QPushButton, - QHBoxLayout, - QLabel, QCheckBox, QComboBox, + QDialog, + QHBoxLayout, + QLabel, + QListView, + QPushButton, + QVBoxLayout, ) -from game.squadrons import Pilot, Squadron -from game.theater import ControlPoint, ConflictTheater from game.ato.flighttype import FlightType +from game.squadrons import Pilot, Squadron +from game.theater import ConflictTheater, ControlPoint from qt_ui.delegates import TwoColumnRowDelegate from qt_ui.errorreporter import report_errors -from qt_ui.models import SquadronModel, AtoModel +from qt_ui.models import AtoModel, SquadronModel class PilotDelegate(TwoColumnRowDelegate): @@ -144,7 +139,6 @@ class SquadronDialog(QDialog): super().__init__(parent) self.ato_model = ato_model self.squadron_model = squadron_model - self.theater = theater self.setMinimumSize(1000, 440) self.setWindowTitle(str(squadron_model.squadron)) @@ -200,7 +194,7 @@ class SquadronDialog(QDialog): if destination is None: self.squadron.cancel_relocation() else: - self.squadron.plan_relocation(destination, self.theater) + self.squadron.plan_relocation(destination) self.ato_model.replace_from_game(player=True) def check_disabled_button_states( diff --git a/qt_ui/windows/mission/QPackageDialog.py b/qt_ui/windows/mission/QPackageDialog.py index 92621875..9d3d7ee2 100644 --- a/qt_ui/windows/mission/QPackageDialog.py +++ b/qt_ui/windows/mission/QPackageDialog.py @@ -16,7 +16,6 @@ from PySide2.QtWidgets import ( ) from game.ato.flight import Flight -from game.ato.flightplans.flightplanbuilder import FlightPlanBuilder from game.ato.flightplans.planningerror import PlanningError from game.ato.package import Package from game.game import Game @@ -181,11 +180,8 @@ class QPackageDialog(QDialog): def add_flight(self, flight: Flight) -> None: """Adds the new flight to the package.""" self.package_model.add_flight(flight) - planner = FlightPlanBuilder( - self.package_model.package, self.game.blue, self.game.theater - ) try: - planner.populate_flight_plan(flight) + flight.recreate_flight_plan() self.package_model.update_tot() EventStream.put_nowait(GameUpdateEvents().new_flight(flight)) except PlanningError as ex: diff --git a/qt_ui/windows/mission/flight/settings/FlightAirfieldDisplay.py b/qt_ui/windows/mission/flight/settings/FlightAirfieldDisplay.py index 9371e317..618f2286 100644 --- a/qt_ui/windows/mission/flight/settings/FlightAirfieldDisplay.py +++ b/qt_ui/windows/mission/flight/settings/FlightAirfieldDisplay.py @@ -4,7 +4,6 @@ from PySide2.QtWidgets import QGroupBox, QLabel, QMessageBox, QVBoxLayout from game import Game from game.ato.flight import Flight -from game.ato.flightplans.flightplanbuilder import FlightPlanBuilder from game.ato.flightplans.planningerror import PlanningError from game.ato.traveltime import TotEstimator from qt_ui.models import PackageModel @@ -71,16 +70,10 @@ class FlightAirfieldDisplay(QGroupBox): self.flight.divert = divert try: - self.update_flight_plan() + self.flight.recreate_flight_plan() except PlanningError as ex: self.flight.divert = old_divert logging.exception("Could not change divert airfield") QMessageBox.critical( self, "Could not update flight plan", str(ex), QMessageBox.Ok ) - - def update_flight_plan(self) -> None: - planner = FlightPlanBuilder( - self.package_model.package, self.game.blue, self.game.theater - ) - planner.populate_flight_plan(self.flight) diff --git a/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py b/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py index bf79f925..8650c9ee 100644 --- a/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py +++ b/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py @@ -1,5 +1,5 @@ import logging -from typing import Iterable, List, Optional, Any +from typing import Iterable, List, Optional from PySide2.QtCore import Signal from PySide2.QtWidgets import ( @@ -14,10 +14,9 @@ from PySide2.QtWidgets import ( from game import Game from game.ato.flight import Flight from game.ato.flightplans.custom import CustomFlightPlan, CustomLayout -from game.ato.flightplans.flightplan import FlightPlan -from game.ato.flightplans.flightplanbuilder import FlightPlanBuilder from game.ato.flightplans.formationattack import FormationAttackFlightPlan from game.ato.flightplans.planningerror import PlanningError +from game.ato.flightplans.waypointbuilder import WaypointBuilder from game.ato.flighttype import FlightType from game.ato.flightwaypoint import FlightWaypoint from game.ato.loadouts import Loadout @@ -38,7 +37,6 @@ class QFlightWaypointTab(QFrame): self.game = game self.package = package self.flight = flight - self.planner = FlightPlanBuilder(package, game.blue, game.theater) self.flight_waypoint_list: Optional[QFlightWaypointList] = None self.rtb_waypoint: Optional[QPushButton] = None @@ -139,7 +137,7 @@ class QFlightWaypointTab(QFrame): self.on_change() def on_rtb_waypoint(self): - rtb = self.planner.generate_rtb_waypoint(self.flight, self.flight.from_cp) + rtb = WaypointBuilder(self.flight, self.coalition).land(self.flight.arrival) self.degrade_to_custom_flight_plan() assert isinstance(self.flight.flight_plan, CustomFlightPlan) self.flight.flight_plan.layout.custom_waypoints.append(rtb) @@ -168,7 +166,7 @@ class QFlightWaypointTab(QFrame): if result == QMessageBox.Yes: self.flight.flight_type = task try: - self.planner.populate_flight_plan(self.flight) + self.flight.recreate_flight_plan() except PlanningError as ex: self.flight.flight_type = original_task logging.exception("Could not recreate flight")