From 48938fc529c078ad5dd6fff42b88fcb355a31d07 Mon Sep 17 00:00:00 2001 From: Raffson Date: Wed, 24 Aug 2022 19:25:30 +0200 Subject: [PATCH] Dan's massive refactor Squashing 8 commits by DanAlbert: - Track theater in ControlPoint. Simplifies finding the owning theater of a control point. Not used yet. - Clean some cruft out of FlightPlanBuilder. - Clean up silly some exception handling. - Move FlightPlan instantiation into the builder. I'm working on moving the builder to be owned by the Flight, which will simplify callers that need to create (or recreate) flight plans for a flight. - Simplify IBuilder constructor. We have access to the theater via the flight's departure airbase now. - Move FlightPlan creation into Flight. For now this is just a callsite cleanup. Later, this will make it easier to separate unscheduled and scheduled flights into different classes without complicating the layout/scheduling. - Remove superfluous constructors. - Remove unused Package field. --- doc/design/flight-creation.md | 0 game/ato/flight.py | 32 ++- game/ato/flightplans/aewc.py | 51 ++--- game/ato/flightplans/airassault.py | 104 +++++----- game/ato/flightplans/airlift.py | 105 +++++----- game/ato/flightplans/antiship.py | 7 +- game/ato/flightplans/bai.py | 7 +- game/ato/flightplans/barcap.py | 45 ++-- game/ato/flightplans/capbuilder.py | 9 +- game/ato/flightplans/cas.py | 105 +++++----- game/ato/flightplans/custom.py | 13 +- game/ato/flightplans/dead.py | 7 +- game/ato/flightplans/escort.py | 7 +- game/ato/flightplans/ferry.py | 63 +++--- game/ato/flightplans/flightplan.py | 10 +- game/ato/flightplans/flightplanbuilder.py | 196 ------------------ .../ato/flightplans/flightplanbuildertypes.py | 66 ++++++ game/ato/flightplans/formationattack.py | 4 +- game/ato/flightplans/ibuilder.py | 24 ++- game/ato/flightplans/ocaaircraft.py | 7 +- game/ato/flightplans/ocarunway.py | 7 +- game/ato/flightplans/packagerefueling.py | 107 +++++----- game/ato/flightplans/refuelingflightplan.py | 20 ++ game/ato/flightplans/rtb.py | 75 +++---- game/ato/flightplans/sead.py | 11 +- game/ato/flightplans/strike.py | 7 +- game/ato/flightplans/sweep.py | 105 +++++----- game/ato/flightplans/tarcap.py | 79 +++---- game/ato/flightplans/theaterrefueling.py | 44 ++-- game/ato/package.py | 25 ++- game/ato/packagewaypoints.py | 46 +++- game/campaignloader/mizcampaignloader.py | 19 +- game/commander/packagefulfiller.py | 8 +- game/squadrons/squadron.py | 24 +-- game/theater/controlpoint.py | 57 +++-- game/transfers.py | 6 +- qt_ui/windows/SquadronDialog.py | 28 +-- qt_ui/windows/mission/QPackageDialog.py | 6 +- .../flight/settings/FlightAirfieldDisplay.py | 9 +- .../flight/waypoints/QFlightWaypointTab.py | 10 +- 40 files changed, 787 insertions(+), 768 deletions(-) create mode 100644 doc/design/flight-creation.md delete mode 100644 game/ato/flightplans/flightplanbuilder.py create mode 100644 game/ato/flightplans/flightplanbuildertypes.py create mode 100644 game/ato/flightplans/refuelingflightplan.py 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")