diff --git a/game/data/doctrine.py b/game/data/doctrine.py index d81c5484..8d3e1a91 100644 --- a/game/data/doctrine.py +++ b/game/data/doctrine.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from datetime import timedelta from game.utils import nm_to_meter, feet_to_meter @@ -25,11 +26,14 @@ class Doctrine: max_patrol_altitude: int pattern_altitude: int + cap_duration: timedelta cap_min_track_length: int cap_max_track_length: int cap_min_distance_from_cp: int cap_max_distance_from_cp: int + cas_duration: timedelta + MODERN_DOCTRINE = Doctrine( cap=True, @@ -48,10 +52,12 @@ MODERN_DOCTRINE = Doctrine( min_patrol_altitude=feet_to_meter(15000), max_patrol_altitude=feet_to_meter(33000), pattern_altitude=feet_to_meter(5000), + cap_duration=timedelta(minutes=30), cap_min_track_length=nm_to_meter(15), cap_max_track_length=nm_to_meter(40), cap_min_distance_from_cp=nm_to_meter(10), cap_max_distance_from_cp=nm_to_meter(40), + cas_duration=timedelta(minutes=30), ) COLDWAR_DOCTRINE = Doctrine( @@ -71,10 +77,12 @@ COLDWAR_DOCTRINE = Doctrine( min_patrol_altitude=feet_to_meter(10000), max_patrol_altitude=feet_to_meter(24000), pattern_altitude=feet_to_meter(5000), + cap_duration=timedelta(minutes=30), cap_min_track_length=nm_to_meter(12), cap_max_track_length=nm_to_meter(24), cap_min_distance_from_cp=nm_to_meter(8), cap_max_distance_from_cp=nm_to_meter(25), + cas_duration=timedelta(minutes=30), ) WWII_DOCTRINE = Doctrine( @@ -94,8 +102,10 @@ WWII_DOCTRINE = Doctrine( min_patrol_altitude=feet_to_meter(4000), max_patrol_altitude=feet_to_meter(15000), pattern_altitude=feet_to_meter(5000), + cap_duration=timedelta(minutes=30), cap_min_track_length=nm_to_meter(8), cap_max_track_length=nm_to_meter(18), cap_min_distance_from_cp=nm_to_meter(0), cap_max_distance_from_cp=nm_to_meter(5), + cas_duration=timedelta(minutes=30), ) diff --git a/gen/aircraft.py b/gen/aircraft.py index 854a73be..2c1f3d71 100644 --- a/gen/aircraft.py +++ b/gen/aircraft.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging import random from dataclasses import dataclass +from datetime import timedelta from typing import Dict, List, Optional, Type, Union from dcs import helicopters @@ -11,7 +12,6 @@ from dcs.condition import CoalitionHasAirdrome, TimeAfter from dcs.country import Country from dcs.flyingunit import FlyingUnit from dcs.helicopters import UH_1H, helicopter_map -from dcs.mapping import Point from dcs.mission import Mission, StartType from dcs.planes import ( AJS37, @@ -81,7 +81,12 @@ from dcs.mapping import Point from theater import TheaterGroundObject from theater.controlpoint import ControlPoint, ControlPointType from .conflictgen import Conflict -from .flights.traveltime import PackageWaypointTiming, TotEstimator +from .flights.flightplan import ( + CasFlightPlan, + FormationFlightPlan, + PatrollingFlightPlan, +) +from .flights.traveltime import TotEstimator from .naming import namegen from .runways import RunwayAssigner @@ -785,7 +790,6 @@ class AircraftConflictGenerator: for package in ato.packages: if not package.flights: continue - timing = PackageWaypointTiming.for_package(package) for flight in package.flights: culled = self.game.position_culled(flight.from_cp.position) if flight.client_count == 0 and culled: @@ -795,10 +799,10 @@ class AircraftConflictGenerator: group = self.generate_planned_flight(flight.from_cp, country, flight) self.setup_flight_group(group, package, flight, dynamic_runways) - self.create_waypoints(group, package, flight, timing) + self.create_waypoints(group, package, flight) def set_activation_time(self, flight: Flight, group: FlyingGroup, - delay: int) -> None: + delay: timedelta) -> None: # Note: Late activation causes the waypoint TOTs to look *weird* in the # mission editor. Waypoint times will be relative to the group # activation time rather than in absolute local time. A flight delayed @@ -808,20 +812,22 @@ class AircraftConflictGenerator: activation_trigger = TriggerOnce( Event.NoEvent, f"FlightLateActivationTrigger{group.id}") - activation_trigger.add_condition(TimeAfter(seconds=delay)) + activation_trigger.add_condition( + TimeAfter(seconds=int(delay.total_seconds()))) self.prevent_spawn_at_hostile_airbase(flight, activation_trigger) activation_trigger.add_action(ActivateGroup(group.id)) self.m.triggerrules.triggers.append(activation_trigger) def set_startup_time(self, flight: Flight, group: FlyingGroup, - delay: int) -> None: + delay: timedelta) -> None: # Uncontrolled causes the AI unit to spawn, but not begin startup. group.uncontrolled = True activation_trigger = TriggerOnce(Event.NoEvent, f"FlightStartTrigger{group.id}") - activation_trigger.add_condition(TimeAfter(seconds=delay)) + activation_trigger.add_condition( + TimeAfter(seconds=int(delay.total_seconds()))) self.prevent_spawn_at_hostile_airbase(flight, activation_trigger) group.add_trigger_action(StartCommand()) @@ -882,7 +888,6 @@ class AircraftConflictGenerator: at=cp.position) group.points[0].alt = 1500 - flight.group = group return group @staticmethod @@ -1012,8 +1017,8 @@ class AircraftConflictGenerator: self.configure_eplrs(group, flight) - def create_waypoints(self, group: FlyingGroup, package: Package, - flight: Flight, timing: PackageWaypointTiming) -> None: + def create_waypoints( + self, group: FlyingGroup, package: Package, flight: Flight) -> None: for waypoint in flight.points: waypoint.tot = None @@ -1030,7 +1035,7 @@ class AircraftConflictGenerator: for idx, point in enumerate(filtered_points): PydcsWaypointBuilder.for_waypoint( - point, group, flight, timing, self.m + point, group, package, flight, self.m ).build() # Set here rather than when the FlightData is created so they waypoints @@ -1043,7 +1048,7 @@ class AircraftConflictGenerator: estimator = TotEstimator(package) start_time = estimator.mission_start_time(flight) - if start_time > 0: + if start_time.total_seconds() > 0: if self.should_activate_late(flight): # Late activation causes the aircraft to not be spawned until # triggered. @@ -1085,12 +1090,12 @@ class AircraftConflictGenerator: class PydcsWaypointBuilder: def __init__(self, waypoint: FlightWaypoint, group: FlyingGroup, - flight: Flight, timing: PackageWaypointTiming, + package: Package, flight: Flight, mission: Mission) -> None: self.waypoint = waypoint self.group = group + self.package = package self.flight = flight - self.timing = timing self.mission = mission def build(self) -> MovingPoint: @@ -1099,35 +1104,32 @@ class PydcsWaypointBuilder: waypoint.alt_type = self.waypoint.alt_type waypoint.name = String(self.waypoint.name) + tot = self.flight.flight_plan.tot_for_waypoint(self.waypoint) + if tot is not None: + self.set_waypoint_tot(waypoint, tot) return waypoint - def set_waypoint_tot(self, waypoint: MovingPoint, tot: int) -> None: + def set_waypoint_tot(self, waypoint: MovingPoint, tot: timedelta) -> None: self.waypoint.tot = tot - waypoint.ETA = tot + waypoint.ETA = int(tot.total_seconds()) waypoint.ETA_locked = True waypoint.speed_locked = False @classmethod def for_waypoint(cls, waypoint: FlightWaypoint, group: FlyingGroup, - flight: Flight, timing: PackageWaypointTiming, + package: Package, flight: Flight, mission: Mission) -> PydcsWaypointBuilder: builders = { - FlightWaypointType.EGRESS: EgressPointBuilder, FlightWaypointType.INGRESS_CAS: CasIngressBuilder, - FlightWaypointType.INGRESS_ESCORT: IngressBuilder, FlightWaypointType.INGRESS_SEAD: SeadIngressBuilder, FlightWaypointType.INGRESS_STRIKE: StrikeIngressBuilder, FlightWaypointType.JOIN: JoinPointBuilder, FlightWaypointType.LANDING_POINT: LandingPointBuilder, FlightWaypointType.LOITER: HoldPointBuilder, FlightWaypointType.PATROL_TRACK: RaceTrackBuilder, - FlightWaypointType.SPLIT: SplitPointBuilder, - FlightWaypointType.TARGET_GROUP_LOC: TargetPointBuilder, - FlightWaypointType.TARGET_POINT: TargetPointBuilder, - FlightWaypointType.TARGET_SHIP: TargetPointBuilder, } builder = builders.get(waypoint.waypoint_type, DefaultWaypointBuilder) - return builder(waypoint, group, flight, timing, mission) + return builder(waypoint, group, package, flight, mission) class DefaultWaypointBuilder(PydcsWaypointBuilder): @@ -1141,32 +1143,35 @@ class HoldPointBuilder(PydcsWaypointBuilder): altitude=waypoint.alt, pattern=OrbitAction.OrbitPattern.Circle )) - push_time = self.timing.push_time(self.flight, self.waypoint) + if not isinstance(self.flight.flight_plan, FormationFlightPlan): + flight_plan_type = self.flight.flight_plan.__class__.__name__ + logging.error( + f"Cannot configure hold for for {self.flight} because " + f"{flight_plan_type} does not define a push time. AI will push " + "immediately and may flight unsuitable speeds." + ) + return waypoint + push_time = self.flight.flight_plan.push_time self.waypoint.departure_time = push_time - loiter.stop_after_time(push_time) + loiter.stop_after_time(int(push_time.total_seconds())) waypoint.add_task(loiter) return waypoint -class EgressPointBuilder(PydcsWaypointBuilder): +class CasIngressBuilder(PydcsWaypointBuilder): def build(self) -> MovingPoint: waypoint = super().build() - self.set_waypoint_tot(waypoint, self.timing.egress) - return waypoint - - -class IngressBuilder(PydcsWaypointBuilder): - def build(self) -> MovingPoint: - waypoint = super().build() - self.set_waypoint_tot(waypoint, self.timing.ingress) - return waypoint - - -class CasIngressBuilder(IngressBuilder): - def build(self) -> MovingPoint: - waypoint = super().build() - cas_waypoint = self.flight.waypoint_with_type((FlightWaypointType.CAS,)) - if cas_waypoint is None: + if isinstance(self.flight.flight_plan, CasFlightPlan): + waypoint.add_task(EngageTargetsInZone( + position=self.flight.flight_plan.target, + radius=FRONTLINE_LENGTH / 2, + targets=[ + Targets.All.GroundUnits.GroundVehicles, + Targets.All.GroundUnits.AirDefence.AAA, + Targets.All.GroundUnits.Infantry, + ]) + ) + else: logging.error( "No CAS waypoint found. Falling back to search and engage") waypoint.add_task(EngageTargets( @@ -1177,25 +1182,15 @@ class CasIngressBuilder(IngressBuilder): Targets.All.GroundUnits.Infantry, ]) ) - else: - waypoint.add_task(EngageTargetsInZone( - position=cas_waypoint.position, - radius=FRONTLINE_LENGTH / 2, - targets=[ - Targets.All.GroundUnits.GroundVehicles, - Targets.All.GroundUnits.AirDefence.AAA, - Targets.All.GroundUnits.Infantry, - ]) - ) waypoint.add_task(OptROE(OptROE.Values.OpenFireWeaponFree)) return waypoint -class SeadIngressBuilder(IngressBuilder): +class SeadIngressBuilder(PydcsWaypointBuilder): def build(self) -> MovingPoint: waypoint = super().build() - target_group = self.waypoint.targetGroup + target_group = self.package.target if isinstance(target_group, TheaterGroundObject): tgroup = self.mission.find_group(target_group.group_identifier) if tgroup is not None: @@ -1218,7 +1213,7 @@ class SeadIngressBuilder(IngressBuilder): return waypoint -class StrikeIngressBuilder(IngressBuilder): +class StrikeIngressBuilder(PydcsWaypointBuilder): def build(self) -> MovingPoint: if self.group.units[0].unit_type == B_17G: return self.build_bombing() @@ -1265,7 +1260,6 @@ class StrikeIngressBuilder(IngressBuilder): class JoinPointBuilder(PydcsWaypointBuilder): def build(self) -> MovingPoint: waypoint = super().build() - self.set_waypoint_tot(waypoint, self.timing.join) if self.flight.flight_type == FlightType.ESCORT: self.configure_escort_tasks(waypoint) return waypoint @@ -1321,27 +1315,20 @@ class RaceTrackBuilder(PydcsWaypointBuilder): def build(self) -> MovingPoint: waypoint = super().build() + if not isinstance(self.flight.flight_plan, PatrollingFlightPlan): + flight_plan_type = self.flight.flight_plan.__class__.__name__ + logging.error( + f"Cannot create race track for {self.flight} because " + f"{flight_plan_type} does not define a patrol.") + return waypoint + racetrack = ControlledTask(OrbitAction( altitude=waypoint.alt, pattern=OrbitAction.OrbitPattern.RaceTrack )) - - self.set_waypoint_tot(waypoint, - self.timing.race_track_start(self.flight)) - racetrack.stop_after_time(self.timing.race_track_end(self.flight)) + self.set_waypoint_tot( + waypoint, self.flight.flight_plan.patrol_start_time) + racetrack.stop_after_time( + int(self.flight.flight_plan.patrol_end_time.total_seconds())) waypoint.add_task(racetrack) return waypoint - - -class SplitPointBuilder(PydcsWaypointBuilder): - def build(self) -> MovingPoint: - waypoint = super().build() - self.set_waypoint_tot(waypoint, self.timing.split) - return waypoint - - -class TargetPointBuilder(PydcsWaypointBuilder): - def build(self) -> MovingPoint: - waypoint = super().build() - self.set_waypoint_tot(waypoint, self.timing.target) - return waypoint diff --git a/gen/ato.py b/gen/ato.py index f4877125..69e868df 100644 --- a/gen/ato.py +++ b/gen/ato.py @@ -11,12 +11,14 @@ the single CAP flight. import logging from collections import defaultdict from dataclasses import dataclass, field +from datetime import timedelta from typing import Dict, List, Optional from dcs.mapping import Point from theater.missiontarget import MissionTarget from .flights.flight import Flight, FlightType +from .flights.flightplan import FormationFlightPlan @dataclass(frozen=True) @@ -51,11 +53,67 @@ class Package: delay: int = field(default=0) - #: Desired TOT measured in seconds from mission start. - time_over_target: int = field(default=0) + #: Desired TOT as an offset from mission start. + time_over_target: timedelta = field(default=timedelta()) waypoints: Optional[PackageWaypoints] = field(default=None) + @property + def formation_speed(self) -> Optional[int]: + """The speed of the package when in formation. + + If none of the flights in the package will join a formation, this + returns None. This is nto uncommon, since only strike-like (strike, + DEAD, anti-ship, BAI, etc.) flights and their escorts fly in formation. + Others (CAP and CAS, currently) will coordinate in target timing but + fly their own path to the target. + """ + speeds = [] + for flight in self.flights: + if isinstance(flight.flight_plan, FormationFlightPlan): + speeds.append(flight.flight_plan.best_flight_formation_speed) + if not speeds: + return None + return min(speeds) + + # TODO: Should depend on the type of escort. + # SEAD might be able to leave before CAP. + @property + def escort_start_time(self) -> Optional[timedelta]: + times = [] + for flight in self.flights: + waypoint = flight.flight_plan.request_escort_at() + if waypoint is None: + continue + tot = flight.flight_plan.tot_for_waypoint(waypoint) + if tot is None: + logging.error( + f"{flight} requested escort at {waypoint} but that " + "waypoint has no TOT. It may not be escorted.") + continue + times.append(tot) + if times: + return min(times) + return None + + @property + def escort_end_time(self) -> Optional[timedelta]: + times = [] + for flight in self.flights: + waypoint = flight.flight_plan.dismiss_escort_at() + if waypoint is None: + continue + tot = flight.flight_plan.tot_for_waypoint(waypoint) + if tot is None: + logging.error( + f"{flight} dismissed escort at {waypoint} but that " + "waypoint has no TOT. It may not be escorted.") + continue + times.append(tot) + if times: + return max(times) + return None + def add_flight(self, flight: Flight) -> None: """Adds a flight to the package.""" self.flights.append(flight) diff --git a/gen/flights/ai_flight_planner.py b/gen/flights/ai_flight_planner.py index c3157eee..b132b386 100644 --- a/gen/flights/ai_flight_planner.py +++ b/gen/flights/ai_flight_planner.py @@ -1,9 +1,10 @@ from __future__ import annotations import logging -import random import operator +import random from dataclasses import dataclass +from datetime import timedelta from typing import Iterator, List, Optional, Set, TYPE_CHECKING, Tuple, Type from dcs.unittype import FlyingType, UnitType @@ -12,7 +13,7 @@ from game import db from game.data.radar_db import UNITS_WITH_RADAR from game.infos.information import Information from game.utils import nm_to_meter -from gen import Conflict, PackageWaypointTiming +from gen import Conflict from gen.ato import Package from gen.flights.ai_flight_planner_db import ( CAP_CAPABLE, @@ -483,11 +484,11 @@ class CoalitionMissionPlanner: def stagger_missions(self) -> None: def start_time_generator(count: int, earliest: int, latest: int, - margin: int) -> Iterator[int]: + margin: int) -> Iterator[timedelta]: interval = latest // count for time in range(earliest, latest, interval): error = random.randint(-margin, margin) - yield max(0, time + error) + yield timedelta(minutes=max(0, time + error)) dca_types = (FlightType.BARCAP, FlightType.INTERCEPTION) @@ -512,7 +513,7 @@ class CoalitionMissionPlanner: # airfields to hit grounded aircraft, since they're more likely # to be present. Runway and air started aircraft will be # delayed until their takeoff time by AirConflictGenerator. - package.time_over_target = next(start_time) * 60 + tot + package.time_over_target = next(start_time) + tot def message(self, title, text) -> None: """Emits a planning message to the player. diff --git a/gen/flights/flight.py b/gen/flights/flight.py index 2fd9a7fe..c0c3e2eb 100644 --- a/gen/flights/flight.py +++ b/gen/flights/flight.py @@ -1,7 +1,8 @@ from __future__ import annotations +from datetime import timedelta from enum import Enum -from typing import Dict, Iterable, List, Optional, TYPE_CHECKING +from typing import Dict, List, Optional, TYPE_CHECKING from dcs.mapping import Point from dcs.point import MovingPoint, PointAction @@ -12,6 +13,7 @@ from theater.controlpoint import ControlPoint, MissionTarget if TYPE_CHECKING: from gen.ato import Package + from gen.flights.flightplan import FlightPlan class FlightType(Enum): @@ -60,17 +62,6 @@ class FlightWaypointType(Enum): INGRESS_ESCORT = 19 -class PredefinedWaypointCategory(Enum): - NOT_PREDEFINED = 0 - ALLY_CP = 1 - ENEMY_CP = 2 - FRONTLINE = 3 - ENEMY_BUILDING = 4 - ENEMY_UNIT = 5 - ALLY_BUILDING = 6 - ALLY_UNIT = 7 - - class FlightWaypoint: def __init__(self, waypoint_type: FlightWaypointType, x: float, y: float, @@ -92,19 +83,16 @@ class FlightWaypoint: self.name = "" self.description = "" self.targets: List[MissionTarget] = [] - self.targetGroup: Optional[MissionTarget] = None self.obj_name = "" self.pretty_name = "" - self.category: PredefinedWaypointCategory = PredefinedWaypointCategory.NOT_PREDEFINED self.only_for_player = False - self.data = None # These are set very late by the air conflict generator (part of mission # generation). We do it late so that we don't need to propagate changes # to waypoint times whenever the player alters the package TOT or the # flight's offset in the UI. - self.tot: Optional[int] = None - self.departure_time: Optional[int] = None + self.tot: Optional[timedelta] = None + self.departure_time: Optional[timedelta] = None @property def position(self) -> Point: @@ -138,11 +126,6 @@ class FlightWaypoint: class Flight: - count: int = 0 - client_count: int = 0 - use_custom_loadout = False - preset_loadout_name = "" - group = False # Contains DCS Mission group data after mission has been generated def __init__(self, package: Package, unit_type: UnitType, count: int, from_cp: ControlPoint, flight_type: FlightType, @@ -152,7 +135,7 @@ class Flight: self.count = count self.from_cp = from_cp self.flight_type = flight_type - self.points: List[FlightWaypoint] = [] + # TODO: Replace with FlightPlan. self.targets: List[MissionTarget] = [] self.loadout: Dict[str, str] = {} self.start_type = start_type @@ -161,15 +144,23 @@ class Flight: # mission's TOT and the other flights in the package. Takeoff time is # determined by AirConflictGenerator. self.scheduled_in = 0 + self.use_custom_loadout = False + self.client_count = 0 + + # Will be replaced with a more appropriate FlightPlan by + # FlightPlanBuilder, but an empty flight plan the flight begins with an + # empty flight plan. + from gen.flights.flightplan import CustomFlightPlan + self.flight_plan: FlightPlan = CustomFlightPlan( + package=package, + flight=self, + custom_waypoints=[] + ) + + @property + def points(self) -> List[FlightWaypoint]: + return self.flight_plan.waypoints[1:] def __repr__(self): return self.flight_type.name + " | " + str(self.count) + "x" + db.unit_type_name(self.unit_type) \ + " (" + str(len(self.points)) + " wpt)" - - def waypoint_with_type( - self, - types: Iterable[FlightWaypointType]) -> Optional[FlightWaypoint]: - for waypoint in self.points: - if waypoint.waypoint_type in types: - return waypoint - return None diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py index 3141707b..bbc954d8 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -7,27 +7,43 @@ generating the waypoints for the mission. """ from __future__ import annotations +from datetime import timedelta +from functools import cached_property import logging import random -from typing import List, Optional, TYPE_CHECKING +from dataclasses import dataclass +from typing import Iterator, List, Optional, Set, TYPE_CHECKING, Tuple from dcs.mapping import Point from dcs.unit import Unit -from game.data.doctrine import Doctrine, MODERN_DOCTRINE +from game.data.doctrine import Doctrine from game.utils import nm_to_meter -from gen.ato import Package, PackageWaypoints from theater import ControlPoint, FrontLine, MissionTarget, TheaterGroundObject from .closestairfields import ObjectiveDistanceCache -from .flight import Flight, FlightType, FlightWaypoint -from .waypointbuilder import WaypointBuilder +from .flight import Flight, FlightType, FlightWaypoint, FlightWaypointType +from .traveltime import GroundSpeed, TravelTime +from .waypointbuilder import StrikeTarget, WaypointBuilder from ..conflictgen import Conflict if TYPE_CHECKING: from game import Game + from gen.ato import Package -class InvalidObjectiveLocation(RuntimeError): +INGRESS_TYPES = { + FlightWaypointType.INGRESS_CAS, + FlightWaypointType.INGRESS_ESCORT, + FlightWaypointType.INGRESS_SEAD, + FlightWaypointType.INGRESS_STRIKE, +} + + +class PlanningError(RuntimeError): + """Raised when the flight planner was unable to create a flight plan.""" + + +class InvalidObjectiveLocation(PlanningError): """Raised when the objective location is invalid for the mission type.""" def __init__(self, task: FlightType, location: MissionTarget) -> None: super().__init__( @@ -35,10 +51,473 @@ class InvalidObjectiveLocation(RuntimeError): ) +@dataclass(frozen=True) +class PackageWaypointTiming: + #: The package being scheduled. + package: Package + + #: The package join time. + join: timedelta + + #: The ingress waypoint TOT. + ingress: timedelta + + #: The egress waypoint TOT. + egress: timedelta + + #: The package split time. + split: timedelta + + @property + def target(self) -> timedelta: + """The package time over target.""" + return self.package.time_over_target + + @classmethod + def for_package(cls, package: Package) -> Optional[PackageWaypointTiming]: + """Computes and returns the timings for package formation waypoints. + + Package waypoint timing depends on the composition of the package. + Whenever flights are added or removed they must be recomputed since the + mission speed may have changed. + + If the package contains no flights with formation flight plans, this + returns None. + """ + assert package.waypoints is not None + + if not package.flights: + raise ValueError("Cannot plan TOT for package with no flights") + + group_ground_speed = package.formation_speed + if group_ground_speed is None: + return None + + ingress = package.time_over_target - TravelTime.between_points( + package.waypoints.ingress, + package.target.position, + group_ground_speed + ) + + join = ingress - TravelTime.between_points( + package.waypoints.join, + package.waypoints.ingress, + group_ground_speed + ) + + egress = package.time_over_target + TravelTime.between_points( + package.target.position, + package.waypoints.egress, + group_ground_speed + ) + + split = egress + TravelTime.between_points( + package.waypoints.egress, + package.waypoints.split, + group_ground_speed + ) + + return cls(package, join, ingress, egress, split) + + +@dataclass(frozen=True) +class FlightPlan: + package: Package + flight: Flight + + @property + def waypoints(self) -> List[FlightWaypoint]: + """A list of all waypoints in the flight plan, in order.""" + raise NotImplementedError + + @property + def edges(self) -> Iterator[Tuple[FlightWaypoint, FlightWaypoint]]: + """A list of all paths between waypoints, in order.""" + return zip(self.waypoints, self.waypoints[1:]) + + def best_speed_between_waypoints(self, a: FlightWaypoint, + b: FlightWaypoint) -> int: + """Desired ground speed between points a and b.""" + factor = 1.0 + if b.waypoint_type == FlightWaypointType.ASCEND_POINT: + # Flights that start airborne already have some altitude and a good + # amount of speed. + factor = 0.5 + # TODO: Adjust if AGL. + # We don't have an exact heightmap, but we should probably be performing + # *some* adjustment for NTTR since the minimum altitude of the map is + # near 2000 ft MSL. + return int( + GroundSpeed.for_flight(self.flight, min(a.alt, b.alt)) * factor) + + def speed_between_waypoints(self, a: FlightWaypoint, + b: FlightWaypoint) -> int: + return self.best_speed_between_waypoints(a, b) + + @property + def tot_waypoint(self) -> Optional[FlightWaypoint]: + """The waypoint that is associated with the package TOT, or None. + + Note that the only flight plans that should have no target waypoints are + user-planned missions without any useful waypoints and flight plans that + failed to generate. Nevertheless, we have to defend against it. + """ + raise NotImplementedError + + # Not cached because changes to the package might alter the formation speed. + @property + def travel_time_to_target(self) -> Optional[timedelta]: + """The estimated time between the first waypoint and the target.""" + if self.tot_waypoint is None: + return None + return self._travel_time_to_waypoint(self.tot_waypoint) + + def _travel_time_to_waypoint( + self, destination: FlightWaypoint) -> timedelta: + total = timedelta() + for previous_waypoint, waypoint in self.edges: + total += TravelTime.between_points( + previous_waypoint.position, waypoint.position, + self.speed_between_waypoints(previous_waypoint, waypoint)) + if waypoint == destination: + break + else: + raise PlanningError( + f"Did not find destination waypoint {destination} in " + f"waypoints for {self.flight}") + return total + + def tot_for_waypoint(self, waypoint: FlightWaypoint) -> Optional[timedelta]: + raise NotImplementedError + + def depart_time_for_waypoint( + self, waypoint: FlightWaypoint) -> Optional[timedelta]: + raise NotImplementedError + + def request_escort_at(self) -> Optional[FlightWaypoint]: + return None + + def dismiss_escort_at(self) -> Optional[FlightWaypoint]: + return None + + +@dataclass(frozen=True) +class FormationFlightPlan(FlightPlan): + hold: FlightWaypoint + join: FlightWaypoint + split: FlightWaypoint + + @property + def package_timing(self) -> PackageWaypointTiming: + timing = PackageWaypointTiming.for_package(self.package) + # Should be able to create a PackageWaypointTiming for any package with + # a FormationFlightPlan flight. + assert timing is not None + return timing + + @property + def waypoints(self) -> List[FlightWaypoint]: + raise NotImplementedError + + @property + def package_speed_waypoints(self) -> Set[FlightWaypoint]: + raise NotImplementedError + + @property + def tot_waypoint(self) -> Optional[FlightWaypoint]: + raise NotImplementedError + + def request_escort_at(self) -> Optional[FlightWaypoint]: + return self.join + + def dismiss_escort_at(self) -> Optional[FlightWaypoint]: + return self.split + + @cached_property + def best_flight_formation_speed(self) -> int: + """The best speed this flight is capable at all formation waypoints. + + To ease coordination with other flights, we aim to have a single mission + speed used by the formation for all waypoints. As such, this function + returns the highest ground speed that the flight is capable of flying at + all of its formation waypoints. + """ + speeds = [] + for previous_waypoint, waypoint in self.edges: + if waypoint in self.package_speed_waypoints: + speeds.append(self.best_speed_between_waypoints( + previous_waypoint, waypoint)) + return min(speeds) + + def speed_between_waypoints(self, a: FlightWaypoint, + b: FlightWaypoint) -> int: + if b in self.package_speed_waypoints: + # Should be impossible, as any package with at least one + # FormationFlightPlan flight needs a formation speed. + assert self.package.formation_speed is not None + return self.package.formation_speed + return super().speed_between_waypoints(a, b) + + @property + def travel_time_to_rendezvous(self) -> timedelta: + """The estimated time between the first waypoint and the join point.""" + return self._travel_time_to_waypoint(self.join) + + def tot_for_waypoint(self, waypoint: FlightWaypoint) -> Optional[timedelta]: + target_types = ( + FlightWaypointType.TARGET_GROUP_LOC, + FlightWaypointType.TARGET_POINT, + FlightWaypointType.TARGET_SHIP, + ) + + if waypoint.waypoint_type == FlightWaypointType.JOIN: + return self.package_timing.join + elif waypoint.waypoint_type in INGRESS_TYPES: + return self.package_timing.ingress + elif waypoint.waypoint_type in target_types: + return self.package_timing.target + elif waypoint.waypoint_type == FlightWaypointType.EGRESS: + return self.package_timing.egress + elif waypoint.waypoint_type == FlightWaypointType.SPLIT: + return self.package_timing.split + return None + + def depart_time_for_waypoint( + self, waypoint: FlightWaypoint) -> Optional[timedelta]: + if waypoint == self.hold: + return self.push_time + return None + + @property + def push_time(self) -> timedelta: + return self.package_timing.join - TravelTime.between_points( + self.hold.position, + self.join.position, + GroundSpeed.for_flight(self.flight, self.hold.alt) + ) + + +@dataclass(frozen=True) +class PatrollingFlightPlan(FlightPlan): + patrol_start: FlightWaypoint + patrol_end: FlightWaypoint + + #: Maximum time to remain on station. + patrol_duration: timedelta + + @property + def patrol_start_time(self) -> timedelta: + return self.package.time_over_target + + @property + def patrol_end_time(self) -> timedelta: + # TODO: This is currently wrong for CAS. + # CAS missions end when they're winchester or bingo. We need to + # configure push tasks for the escorts rather than relying on timing. + return self.patrol_start_time + self.patrol_duration + + def tot_for_waypoint(self, waypoint: FlightWaypoint) -> Optional[timedelta]: + if waypoint == self.patrol_start: + return self.patrol_start_time + return None + + def depart_time_for_waypoint( + self, waypoint: FlightWaypoint) -> Optional[timedelta]: + if waypoint == self.patrol_end: + return self.patrol_end_time + return None + + @property + def waypoints(self) -> List[FlightWaypoint]: + raise NotImplementedError + + @property + def package_speed_waypoints(self) -> Set[FlightWaypoint]: + return {self.patrol_start, self.patrol_end} + + @property + def tot_waypoint(self) -> Optional[FlightWaypoint]: + return self.patrol_start + + +@dataclass(frozen=True) +class BarCapFlightPlan(PatrollingFlightPlan): + takeoff: FlightWaypoint + ascent: FlightWaypoint + descent: FlightWaypoint + land: FlightWaypoint + + @property + def waypoints(self) -> List[FlightWaypoint]: + return [ + self.takeoff, + self.ascent, + self.patrol_start, + self.patrol_end, + self.descent, + self.land, + ] + + +@dataclass(frozen=True) +class CasFlightPlan(PatrollingFlightPlan): + takeoff: FlightWaypoint + ascent: FlightWaypoint + target: FlightWaypoint + descent: FlightWaypoint + land: FlightWaypoint + + @property + def waypoints(self) -> List[FlightWaypoint]: + return [ + self.takeoff, + self.ascent, + self.patrol_start, + self.target, + self.patrol_end, + self.descent, + self.land, + ] + + def request_escort_at(self) -> Optional[FlightWaypoint]: + return self.patrol_start + + def dismiss_escort_at(self) -> Optional[FlightWaypoint]: + return self.patrol_end + + +@dataclass(frozen=True) +class FrontLineCapFlightPlan(PatrollingFlightPlan): + takeoff: FlightWaypoint + ascent: FlightWaypoint + descent: FlightWaypoint + land: FlightWaypoint + + @property + def waypoints(self) -> List[FlightWaypoint]: + return [ + self.takeoff, + self.ascent, + self.patrol_start, + self.patrol_end, + self.descent, + self.land, + ] + + def depart_time_for_waypoint( + self, waypoint: FlightWaypoint) -> Optional[timedelta]: + if waypoint == self.patrol_end: + timing = PackageWaypointTiming.for_package(self.package) + if timing is None: + # If the player for some reason planned a TAR + patrol_duration = self.patrol_duration + else: + patrol_duration = timing.egress + return self.package.time_over_target + patrol_duration + return super().depart_time_for_waypoint(waypoint) + + @property + def patrol_start_time(self) -> timedelta: + start = self.package.escort_start_time + if start is not None: + return start + return super().patrol_start_time + + @property + def patrol_end_time(self) -> timedelta: + end = self.package.escort_end_time + if end is not None: + return end + return super().patrol_end_time + + +@dataclass(frozen=True) +class StrikeFlightPlan(FormationFlightPlan): + takeoff: FlightWaypoint + ascent: FlightWaypoint + hold: FlightWaypoint + join: FlightWaypoint + ingress: FlightWaypoint + targets: List[FlightWaypoint] + egress: FlightWaypoint + split: FlightWaypoint + descent: FlightWaypoint + land: FlightWaypoint + + @property + def waypoints(self) -> List[FlightWaypoint]: + return [ + self.takeoff, + self.ascent, + self.hold, + self.join, + self.ingress + ] + self.targets + [ + self.egress, + self.split, + self.descent, + self.land, + ] + + @property + def package_speed_waypoints(self) -> Set[FlightWaypoint]: + return { + self.ingress, + self.egress, + self.split, + } | set(self.targets) + + @property + def tot_waypoint(self) -> Optional[FlightWaypoint]: + return self.targets[0] + + @property + def mission_speed(self) -> int: + return GroundSpeed.for_flight(self.flight, self.ingress.alt) + + +@dataclass(frozen=True) +class CustomFlightPlan(FlightPlan): + custom_waypoints: List[FlightWaypoint] + + @property + def waypoints(self) -> List[FlightWaypoint]: + return self.custom_waypoints + + @property + def tot_waypoint(self) -> Optional[FlightWaypoint]: + target_types = ( + FlightWaypointType.PATROL_TRACK, + FlightWaypointType.TARGET_GROUP_LOC, + FlightWaypointType.TARGET_POINT, + FlightWaypointType.TARGET_SHIP, + ) + for waypoint in self.waypoints: + if waypoint in target_types: + return waypoint + return None + + def tot_for_waypoint(self, waypoint: FlightWaypoint) -> Optional[timedelta]: + if waypoint == self.tot_waypoint: + return self.package.time_over_target + return None + + def depart_time_for_waypoint( + self, waypoint: FlightWaypoint) -> Optional[timedelta]: + return None + + class FlightPlanBuilder: """Generates flight plans for flights.""" def __init__(self, game: Game, package: Package, is_player: bool) -> 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.game = game self.package = package self.is_player = is_player @@ -58,53 +537,38 @@ class FlightPlanBuilder: if self.package.waypoints is None: self.regenerate_package_waypoints() - # TODO: Flesh out mission types. try: - task = flight.flight_type - if task == FlightType.ANTISHIP: - logging.error( - "Anti-ship flight plan generation not implemented" - ) - elif task == FlightType.BAI: - logging.error("BAI flight plan generation not implemented") - elif task == FlightType.BARCAP: - self.generate_barcap(flight) - elif task == FlightType.CAS: - self.generate_cas(flight) - elif task == FlightType.DEAD: - self.generate_sead(flight, custom_targets) - elif task == FlightType.ELINT: - logging.error("ELINT flight plan generation not implemented") - elif task == FlightType.ESCORT: - self.generate_escort(flight) - elif task == FlightType.EVAC: - logging.error("Evac flight plan generation not implemented") - elif task == FlightType.EWAR: - logging.error("EWar flight plan generation not implemented") - elif task == FlightType.INTERCEPTION: - logging.error( - "Intercept flight plan generation not implemented" - ) - elif task == FlightType.LOGISTICS: - logging.error( - "Logistics flight plan generation not implemented" - ) - elif task == FlightType.RECON: - logging.error("Recon flight plan generation not implemented") - elif task == FlightType.SEAD: - self.generate_sead(flight, custom_targets) - elif task == FlightType.STRIKE: - self.generate_strike(flight) - elif task == FlightType.TARCAP: - self.generate_frontline_cap(flight) - elif task == FlightType.TROOP_TRANSPORT: - logging.error( - "Troop transport flight plan generation not implemented" - ) - else: - logging.error(f"Unsupported task type: {task.name}") - except InvalidObjectiveLocation: + flight_plan = self.generate_flight_plan(flight, custom_targets) + except PlanningError: logging.exception(f"Could not create flight plan") + return + flight.flight_plan = flight_plan + + def generate_flight_plan( + self, flight: Flight, + custom_targets: Optional[List[Unit]]) -> FlightPlan: + # TODO: Flesh out mission types. + task = flight.flight_type + if task == FlightType.BARCAP: + return self.generate_barcap(flight) + elif task == FlightType.CAS: + return self.generate_cas(flight) + elif task == FlightType.DEAD: + return self.generate_sead(flight, custom_targets) + elif task == FlightType.ESCORT: + return self.generate_escort(flight) + elif task == FlightType.SEAD: + return self.generate_sead(flight, custom_targets) + elif task == FlightType.STRIKE: + return self.generate_strike(flight) + elif task == FlightType.TARCAP: + return self.generate_frontline_cap(flight) + elif task == FlightType.TROOP_TRANSPORT: + logging.error( + "Troop transport flight plan generation not implemented" + ) + raise PlanningError( + f"{task.name} flight plan generation not implemented") def regenerate_package_waypoints(self) -> None: ingress_point = self._ingress_point() @@ -112,6 +576,7 @@ class FlightPlanBuilder: join_point = self._join_point(ingress_point) split_point = self._split_point(egress_point) + from gen.ato import PackageWaypoints self.package.waypoints = PackageWaypoints( join_point, ingress_point, @@ -119,31 +584,25 @@ class FlightPlanBuilder: split_point, ) - def generate_strike(self, flight: Flight) -> None: + def generate_strike(self, flight: Flight) -> StrikeFlightPlan: """Generates a strike flight plan. Args: flight: The flight to generate the flight plan for. """ - assert self.package.waypoints is not None location = self.package.target # TODO: Support airfield strikes. if not isinstance(location, TheaterGroundObject): raise InvalidObjectiveLocation(flight.flight_type, location) - builder = WaypointBuilder(self.game.conditions, flight, self.doctrine) - builder.ascent(flight.from_cp) - builder.hold(self._hold_point(flight)) - builder.join(self.package.waypoints.join) - builder.ingress_strike(self.package.waypoints.ingress, location) - + targets: List[StrikeTarget] = [] if len(location.groups) > 0 and location.dcs_identifier == "AA": # TODO: Replace with DEAD? # Strike missions on SEAD targets target units. for g in location.groups: for j, u in enumerate(g.units): - builder.strike_point(u, f"{u.type} #{j}", location) + targets.append(StrikeTarget(f"{u.type} #{j}", u)) else: # TODO: Does this actually happen? # ConflictTheater is built with the belief that multiple ground @@ -157,15 +616,11 @@ class FlightPlanBuilder: if building.is_dead: continue - builder.strike_point(building, building.category, location) + targets.append(StrikeTarget(building.category, building)) - builder.egress(self.package.waypoints.egress, location) - builder.split(self.package.waypoints.split) - builder.rtb(flight.from_cp) + return self.strike_flightplan(flight, location, targets) - flight.points = builder.build() - - def generate_barcap(self, flight: Flight) -> None: + def generate_barcap(self, flight: Flight) -> BarCapFlightPlan: """Generate a BARCAP flight at a given location. Args: @@ -191,8 +646,7 @@ class FlightPlanBuilder: closest_airfield = airfield break else: - logging.error("Could not find any enemy airfields") - return + raise PlanningError("Could not find any enemy airfields") heading = location.position.heading_between_point( closest_airfield.position @@ -219,18 +673,27 @@ class FlightPlanBuilder: start = end.point_from_heading(heading - 180, diameter) builder = WaypointBuilder(self.game.conditions, flight, self.doctrine) - builder.ascent(flight.from_cp) - builder.race_track(start, end, patrol_alt) - builder.rtb(flight.from_cp) - flight.points = builder.build() + start, end = builder.race_track(start, end, patrol_alt) + descent, land = builder.rtb(flight.from_cp) - def generate_frontline_cap(self, flight: Flight) -> None: + return BarCapFlightPlan( + package=self.package, + flight=flight, + patrol_duration=self.doctrine.cap_duration, + takeoff=builder.takeoff(flight.from_cp), + ascent=builder.ascent(flight.from_cp), + patrol_start=start, + patrol_end=end, + descent=descent, + land=land + ) + + def generate_frontline_cap(self, flight: Flight) -> FrontLineCapFlightPlan: """Generate a CAP flight plan for the given front line. Args: flight: The flight to generate the flight plan for. """ - assert self.package.waypoints is not None location = self.package.target if not isinstance(location, FrontLine): @@ -261,87 +724,79 @@ class FlightPlanBuilder: # Create points builder = WaypointBuilder(self.game.conditions, flight, self.doctrine) - builder.ascent(flight.from_cp) - builder.hold(self._hold_point(flight)) - builder.join(self.package.waypoints.join) - builder.race_track(orbit0p, orbit1p, patrol_alt) - builder.split(self.package.waypoints.split) - builder.rtb(flight.from_cp) - flight.points = builder.build() + + start, end = builder.race_track(orbit0p, orbit1p, patrol_alt) + descent, land = builder.rtb(flight.from_cp) + return FrontLineCapFlightPlan( + package=self.package, + flight=flight, + # Note that this duration only has an effect if there are no + # flights in the package that have requested escort. If the package + # requests an escort the CAP flight will remain on station for the + # duration of the escorted mission, or until it is winchester/bingo. + patrol_duration=self.doctrine.cap_duration, + takeoff=builder.takeoff(flight.from_cp), + ascent=builder.ascent(flight.from_cp), + patrol_start=start, + patrol_end=end, + descent=descent, + land=land + ) def generate_sead(self, flight: Flight, - custom_targets: Optional[List[Unit]]) -> None: + custom_targets: Optional[List[Unit]]) -> StrikeFlightPlan: """Generate a SEAD/DEAD flight at a given location. Args: flight: The flight to generate the flight plan for. custom_targets: Specific radar equipped units selected by the user. """ - assert self.package.waypoints is not None location = self.package.target if not isinstance(location, TheaterGroundObject): raise InvalidObjectiveLocation(flight.flight_type, location) - if custom_targets is None: - custom_targets = [] - - builder = WaypointBuilder(self.game.conditions, flight, self.doctrine) - builder.ascent(flight.from_cp) - builder.hold(self._hold_point(flight)) - builder.join(self.package.waypoints.join) - builder.ingress_sead(self.package.waypoints.ingress, location) - # TODO: Unify these. # There doesn't seem to be any reason to treat the UI fragged missions # different from the automatic missions. - if custom_targets: + targets: Optional[List[StrikeTarget]] = None + if custom_targets is not None: + targets = [] for target in custom_targets: - if flight.flight_type == FlightType.DEAD: - builder.dead_point(target, location.name, location) - else: - builder.sead_point(target, location.name, location) - else: - if flight.flight_type == FlightType.DEAD: - builder.dead_area(location) - else: - builder.sead_area(location) + targets.append(StrikeTarget(location.name, target)) - builder.egress(self.package.waypoints.egress, location) - builder.split(self.package.waypoints.split) - builder.rtb(flight.from_cp) + return self.strike_flightplan(flight, location, targets) - flight.points = builder.build() - - def _hold_point(self, flight: Flight) -> Point: - heading = flight.from_cp.position.heading_between_point( - self.package.target.position - ) - return flight.from_cp.position.point_from_heading( - heading, nm_to_meter(15) - ) - - def generate_escort(self, flight: Flight) -> None: + def generate_escort(self, flight: Flight) -> StrikeFlightPlan: assert self.package.waypoints is not None builder = WaypointBuilder(self.game.conditions, flight, self.doctrine) - builder.ascent(flight.from_cp) - builder.hold(self._hold_point(flight)) - builder.join(self.package.waypoints.join) - builder.escort(self.package.waypoints.ingress, - self.package.target, self.package.waypoints.egress) - builder.split(self.package.waypoints.split) - builder.rtb(flight.from_cp) + ingress, target, egress = builder.escort( + self.package.waypoints.ingress, self.package.target, + self.package.waypoints.egress) + descent, land = builder.rtb(flight.from_cp) - flight.points = builder.build() + return StrikeFlightPlan( + package=self.package, + flight=flight, + takeoff=builder.takeoff(flight.from_cp), + ascent=builder.ascent(flight.from_cp), + hold=builder.hold(self._hold_point(flight)), + join=builder.join(self.package.waypoints.join), + ingress=ingress, + targets=[target], + egress=egress, + split=builder.split(self.package.waypoints.split), + descent=descent, + land=land + ) - def generate_cas(self, flight: Flight) -> None: + def generate_cas(self, flight: Flight) -> CasFlightPlan: """Generate a CAS flight plan for the given target. Args: flight: The flight to generate the flight plan for. """ - assert self.package.waypoints is not None location = self.package.target if not isinstance(location, FrontLine): @@ -355,16 +810,48 @@ class FlightPlanBuilder: egress = ingress.point_from_heading(heading, distance) builder = WaypointBuilder(self.game.conditions, flight, self.doctrine) - builder.ascent(flight.from_cp) - builder.hold(self._hold_point(flight)) - builder.join(self.package.waypoints.join) - builder.ingress_cas(ingress, location) - builder.cas(center) - builder.egress(egress, location) - builder.split(self.package.waypoints.split) - builder.rtb(flight.from_cp) + descent, land = builder.rtb(flight.from_cp) - flight.points = builder.build() + return CasFlightPlan( + package=self.package, + flight=flight, + patrol_duration=self.doctrine.cas_duration, + takeoff=builder.takeoff(flight.from_cp), + ascent=builder.ascent(flight.from_cp), + patrol_start=builder.ingress_cas(ingress, location), + target=builder.cas(center), + patrol_end=builder.egress(egress, location), + descent=descent, + land=land + ) + + @staticmethod + def target_waypoint(flight: Flight, builder: WaypointBuilder, + target: StrikeTarget) -> FlightWaypoint: + if flight.flight_type == FlightType.DEAD: + return builder.dead_point(target) + elif flight.flight_type == FlightType.SEAD: + return builder.sead_point(target) + else: + return builder.strike_point(target) + + @staticmethod + def target_area_waypoint(flight: Flight, location: MissionTarget, + builder: WaypointBuilder) -> FlightWaypoint: + if flight.flight_type == FlightType.DEAD: + return builder.dead_area(location) + elif flight.flight_type == FlightType.SEAD: + return builder.sead_area(location) + else: + return builder.strike_area(location) + + def _hold_point(self, flight: Flight) -> Point: + heading = flight.from_cp.position.heading_between_point( + self.package.target.position + ) + return flight.from_cp.position.point_from_heading( + heading, nm_to_meter(15) + ) # TODO: Make a model for the waypoint builder and use that in the UI. def generate_ascend_point(self, flight: Flight, @@ -376,8 +863,7 @@ class FlightPlanBuilder: departure: Departure airfield or carrier. """ builder = WaypointBuilder(self.game.conditions, flight, self.doctrine) - builder.ascent(departure) - return builder.build()[0] + return builder.ascent(departure) def generate_descend_point(self, flight: Flight, arrival: ControlPoint) -> FlightWaypoint: @@ -388,8 +874,7 @@ class FlightPlanBuilder: arrival: Arrival airfield or carrier. """ builder = WaypointBuilder(self.game.conditions, flight, self.doctrine) - builder.descent(arrival) - return builder.build()[0] + return builder.descent(arrival) def generate_rtb_waypoint(self, flight: Flight, arrival: ControlPoint) -> FlightWaypoint: @@ -400,8 +885,46 @@ class FlightPlanBuilder: arrival: Arrival airfield or carrier. """ builder = WaypointBuilder(self.game.conditions, flight, self.doctrine) - builder.land(arrival) - return builder.build()[0] + return builder.land(arrival) + + def strike_flightplan( + self, flight: Flight, location: TheaterGroundObject, + targets: Optional[List[StrikeTarget]] = None) -> StrikeFlightPlan: + assert self.package.waypoints is not None + builder = WaypointBuilder(self.game.conditions, flight, self.doctrine, + targets) + sead_types = {FlightType.DEAD, FlightType.SEAD} + if flight.flight_type in sead_types: + ingress = builder.ingress_sead(self.package.waypoints.ingress, + location) + else: + ingress = builder.ingress_strike(self.package.waypoints.ingress, + location) + + target_waypoints: List[FlightWaypoint] = [] + if targets is not None: + for target in targets: + target_waypoints.append( + self.target_waypoint(flight, builder, target)) + else: + target_waypoints.append( + self.target_area_waypoint(flight, location, builder)) + + descent, land = builder.rtb(flight.from_cp) + return StrikeFlightPlan( + package=self.package, + flight=flight, + takeoff=builder.takeoff(flight.from_cp), + ascent=builder.ascent(flight.from_cp), + hold=builder.hold(self._hold_point(flight)), + join=builder.join(self.package.waypoints.join), + ingress=ingress, + targets=target_waypoints, + egress=builder.egress(self.package.waypoints.egress, location), + split=builder.split(self.package.waypoints.split), + descent=descent, + land=land + ) def _join_point(self, ingress_point: Point) -> Point: heading = self._heading_to_package_airfield(ingress_point) diff --git a/gen/flights/traveltime.py b/gen/flights/traveltime.py index 96b04fdf..644e9851 100644 --- a/gen/flights/traveltime.py +++ b/gen/flights/traveltime.py @@ -2,61 +2,20 @@ from __future__ import annotations import logging import math -from dataclasses import dataclass -from typing import Iterable, Optional +from datetime import timedelta +from typing import Optional, TYPE_CHECKING from dcs.mapping import Point from dcs.unittype import FlyingType from game.utils import meter_to_nm -from gen.ato import Package -from gen.flights.flight import ( - Flight, - FlightType, - FlightWaypoint, - FlightWaypointType, -) +from gen.flights.flight import Flight - -CAP_DURATION = 30 # Minutes - -INGRESS_TYPES = { - FlightWaypointType.INGRESS_CAS, - FlightWaypointType.INGRESS_ESCORT, - FlightWaypointType.INGRESS_SEAD, - FlightWaypointType.INGRESS_STRIKE, -} +if TYPE_CHECKING: + from gen.ato import Package class GroundSpeed: - @staticmethod - def mission_speed(package: Package) -> int: - speeds = set() - for flight in package.flights: - # Find a waypoint that matches the mission start waypoint and use - # that for the altitude of the mission. That may not be true for the - # whole mission, but it's probably good enough for now. - waypoint = flight.waypoint_with_type({ - FlightWaypointType.INGRESS_CAS, - FlightWaypointType.INGRESS_ESCORT, - FlightWaypointType.INGRESS_SEAD, - FlightWaypointType.INGRESS_STRIKE, - FlightWaypointType.PATROL_TRACK, - }) - if waypoint is None: - logging.error(f"Could not find ingress point for {flight}.") - if flight.points: - logging.warning( - "Using first waypoint for mission altitude.") - waypoint = flight.points[0] - else: - logging.warning( - "Flight has no waypoints. Assuming mission altitude " - "of 25000 feet.") - waypoint = FlightWaypoint(FlightWaypointType.NAV, 0, 0, - 25000) - speeds.add(GroundSpeed.for_flight(flight, waypoint.alt)) - return min(speeds) @classmethod def for_flight(cls, flight: Flight, altitude: int) -> int: @@ -121,52 +80,53 @@ class GroundSpeed: class TravelTime: @staticmethod - def between_points(a: Point, b: Point, speed: float) -> int: + def between_points(a: Point, b: Point, speed: float) -> timedelta: error_factor = 1.1 distance = meter_to_nm(a.distance_to_point(b)) - hours = distance / speed - seconds = hours * 3600 - return int(seconds * error_factor) + return timedelta(hours=distance / speed * error_factor) class TotEstimator: # An extra five minutes given as wiggle room. Expected to be spent at the # hold point performing any last minute configuration. - HOLD_TIME = 5 * 60 + HOLD_TIME = timedelta(minutes=5) def __init__(self, package: Package) -> None: self.package = package - self.timing = PackageWaypointTiming.for_package(package) - def mission_start_time(self, flight: Flight) -> int: + def mission_start_time(self, flight: Flight) -> timedelta: takeoff_time = self.takeoff_time_for_flight(flight) startup_time = self.estimate_startup(flight) ground_ops_time = self.estimate_ground_ops(flight) return takeoff_time - startup_time - ground_ops_time - def takeoff_time_for_flight(self, flight: Flight) -> int: - stop_types = {FlightWaypointType.JOIN, FlightWaypointType.PATROL_TRACK} - travel_time = self.estimate_waypoints_to_target(flight, stop_types) + def takeoff_time_for_flight(self, flight: Flight) -> timedelta: + travel_time = self.travel_time_to_rendezvous_or_target(flight) if travel_time is None: logging.warning("Found no join point or patrol point. Cannot " f"estimate takeoff time takeoff time for {flight}") # Takeoff immediately. - return 0 + return timedelta() - # BARCAP flights do not coordinate with the rest of the package on join - # or ingress points. - if flight.flight_type == FlightType.BARCAP: - start_time = self.timing.race_track_start(flight) + from gen.flights.flightplan import FormationFlightPlan + if isinstance(flight.flight_plan, FormationFlightPlan): + tot = flight.flight_plan.tot_for_waypoint( + flight.flight_plan.join) + if tot is None: + logging.warning( + "Could not determine the TOT of the join point. Takeoff " + f"time for {flight} will be immediate.") + return timedelta() else: - start_time = self.timing.join - return start_time - travel_time - self.HOLD_TIME + tot = self.package.time_over_target + return tot - travel_time - self.HOLD_TIME - def earliest_tot(self) -> int: + def earliest_tot(self) -> timedelta: return max(( self.earliest_tot_for_flight(f) for f in self.package.flights )) + self.HOLD_TIME - def earliest_tot_for_flight(self, flight: Flight) -> int: + def earliest_tot_for_flight(self, flight: Flight) -> timedelta: """Estimate fastest time from mission start to the target position. For BARCAP flights, this is time to race track start. This ensures that @@ -182,211 +142,47 @@ class TotEstimator: The earliest possible TOT for the given flight in seconds. Returns 0 if an ingress point cannot be found. """ - if flight.flight_type == FlightType.BARCAP: - time_to_target = self.estimate_waypoints_to_target(flight, { - FlightWaypointType.PATROL_TRACK - }) - if time_to_target is None: - logging.warning( - f"Found no race track. Cannot estimate TOT for {flight}") - # Return 0 so this flight's travel time does not affect the rest - # of the package. - return 0 - else: - time_to_ingress = self.estimate_waypoints_to_target( - flight, INGRESS_TYPES - ) - if time_to_ingress is None: - logging.warning( - f"Found no ingress types. Cannot estimate TOT for {flight}") - # Return 0 so this flight's travel time does not affect the rest - # of the package. - return 0 - - assert self.package.waypoints is not None - time_to_target = time_to_ingress + TravelTime.between_points( - self.package.waypoints.ingress, self.package.target.position, - GroundSpeed.mission_speed(self.package)) - return sum([ - self.estimate_startup(flight), - self.estimate_ground_ops(flight), - time_to_target, - ]) + time_to_target = self.travel_time_to_target(flight) + if time_to_target is None: + logging.warning(f"Cannot estimate TOT for {flight}") + # Return 0 so this flight's travel time does not affect the rest + # of the package. + return timedelta() + startup = self.estimate_startup(flight) + ground_ops = self.estimate_ground_ops(flight) + return startup + ground_ops + time_to_target @staticmethod - def estimate_startup(flight: Flight) -> int: + def estimate_startup(flight: Flight) -> timedelta: if flight.start_type == "Cold": if flight.client_count: - return 10 * 60 + return timedelta(minutes=10) else: # The AI doesn't seem to have a real startup procedure. - return 2 * 60 - return 0 + return timedelta(minutes=2) + return timedelta() @staticmethod - def estimate_ground_ops(flight: Flight) -> int: + def estimate_ground_ops(flight: Flight) -> timedelta: if flight.start_type in ("Runway", "In Flight"): - return 0 + return timedelta() if flight.from_cp.is_fleet: - return 2 * 60 + return timedelta(minutes=2) else: - return 5 * 60 + return timedelta(minutes=5) - def estimate_waypoints_to_target( - self, flight: Flight, - stop_types: Iterable[FlightWaypointType]) -> Optional[int]: - total = 0 - # TODO: This is AGL. We want MSL. - previous_altitude = 0 - previous_position = flight.from_cp.position - for waypoint in flight.points: - position = Point(waypoint.x, waypoint.y) - total += TravelTime.between_points( - previous_position, position, - self.speed_to_waypoint(flight, waypoint, previous_altitude) - ) - previous_position = position - previous_altitude = waypoint.alt - if waypoint.waypoint_type in stop_types: - return total + @staticmethod + def travel_time_to_target(flight: Flight) -> Optional[timedelta]: + if flight.flight_plan is None: + return None + return flight.flight_plan.travel_time_to_target - return None - - def speed_to_waypoint(self, flight: Flight, waypoint: FlightWaypoint, - from_altitude: int) -> int: - # TODO: Adjust if AGL. - # We don't have an exact heightmap, but we should probably be performing - # *some* adjustment for NTTR since the minimum altitude of the map is - # near 2000 ft MSL. - alt_for_speed = min(from_altitude, waypoint.alt) - pre_join = (FlightWaypointType.LOITER, FlightWaypointType.JOIN) - if waypoint.waypoint_type == FlightWaypointType.ASCEND_POINT: - # Flights that start airborne already have some altitude and a good - # amount of speed. - factor = 1.0 if flight.start_type == "In Flight" else 0.5 - return int(GroundSpeed.for_flight(flight, alt_for_speed) * factor) - elif waypoint.waypoint_type in pre_join: - return GroundSpeed.for_flight(flight, alt_for_speed) - return GroundSpeed.mission_speed(self.package) - - -@dataclass(frozen=True) -class PackageWaypointTiming: - #: The package being scheduled. - package: Package - - #: The package join time. - join: int - - #: The ingress waypoint TOT. - ingress: int - - #: The egress waypoint TOT. - egress: int - - #: The package split time. - split: int - - @property - def target(self) -> int: - """The package time over target.""" - assert self.package.time_over_target is not None - return self.package.time_over_target - - def race_track_start(self, flight: Flight) -> int: - if flight.flight_type == FlightType.BARCAP: - return self.target - else: - # The only other type that (currently) uses race tracks is TARCAP, - # which is sort of in need of cleanup. TARCAP is only valid on front - # lines and they participate in join points and patrol between the - # ingress and egress points rather than on a race track actually - # pointed at the enemy. - return self.ingress - - def race_track_end(self, flight: Flight) -> int: - if flight.flight_type == FlightType.BARCAP: - return self.target + CAP_DURATION * 60 - else: - # For TARCAP. See the explanation in race_track_start. - return self.egress - - def push_time(self, flight: Flight, hold_point: FlightWaypoint) -> int: - assert self.package.waypoints is not None - return self.join - TravelTime.between_points( - Point(hold_point.x, hold_point.y), - self.package.waypoints.join, - GroundSpeed.for_flight(flight, hold_point.alt) - ) - - def tot_for_waypoint(self, flight: Flight, - waypoint: FlightWaypoint) -> Optional[int]: - target_types = ( - FlightWaypointType.TARGET_GROUP_LOC, - FlightWaypointType.TARGET_POINT, - FlightWaypointType.TARGET_SHIP, - ) - - if waypoint.waypoint_type == FlightWaypointType.JOIN: - return self.join - elif waypoint.waypoint_type in INGRESS_TYPES: - return self.ingress - elif waypoint.waypoint_type in target_types: - return self.target - elif waypoint.waypoint_type == FlightWaypointType.EGRESS: - return self.egress - elif waypoint.waypoint_type == FlightWaypointType.SPLIT: - return self.split - elif waypoint.waypoint_type == FlightWaypointType.PATROL_TRACK: - return self.race_track_start(flight) - return None - - def depart_time_for_waypoint(self, waypoint: FlightWaypoint, - flight: Flight) -> Optional[int]: - if waypoint.waypoint_type == FlightWaypointType.LOITER: - return self.push_time(flight, waypoint) - elif waypoint.waypoint_type == FlightWaypointType.PATROL: - return self.race_track_end(flight) - return None - - @classmethod - def for_package(cls, package: Package) -> PackageWaypointTiming: - assert package.waypoints is not 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. - if not package.flights: - raise ValueError("Cannot plan TOT for package with no flights") - - group_ground_speed = GroundSpeed.mission_speed(package) - - ingress = package.time_over_target - TravelTime.between_points( - package.waypoints.ingress, - package.target.position, - group_ground_speed - ) - - join = ingress - TravelTime.between_points( - package.waypoints.join, - package.waypoints.ingress, - group_ground_speed - ) - - egress = package.time_over_target + TravelTime.between_points( - package.target.position, - package.waypoints.egress, - group_ground_speed - ) - - split = egress + TravelTime.between_points( - package.waypoints.egress, - package.waypoints.split, - group_ground_speed - ) - - return cls(package, join, ingress, egress, split) + @staticmethod + def travel_time_to_rendezvous_or_target( + flight: Flight) -> Optional[timedelta]: + if flight.flight_plan is None: + return None + from gen.flights.flightplan import FormationFlightPlan + if isinstance(flight.flight_plan, FormationFlightPlan): + return flight.flight_plan.travel_time_to_rendezvous + return flight.flight_plan.travel_time_to_target diff --git a/gen/flights/waypointbuilder.py b/gen/flights/waypointbuilder.py index 46801280..1e081ee7 100644 --- a/gen/flights/waypointbuilder.py +++ b/gen/flights/waypointbuilder.py @@ -1,6 +1,7 @@ from __future__ import annotations -from typing import List, Optional, Union +from dataclasses import dataclass +from typing import List, Optional, Tuple, Union from dcs.mapping import Point from dcs.unit import Unit @@ -13,23 +14,50 @@ from .flight import Flight, FlightWaypoint, FlightWaypointType from ..runways import RunwayAssigner +@dataclass(frozen=True) +class StrikeTarget: + name: str + target: Union[TheaterGroundObject, Unit] + + class WaypointBuilder: def __init__(self, conditions: Conditions, flight: Flight, - doctrine: Doctrine) -> None: + doctrine: Doctrine, + targets: Optional[List[StrikeTarget]] = None) -> None: self.conditions = conditions self.flight = flight self.doctrine = doctrine - self.waypoints: List[FlightWaypoint] = [] - self.ingress_point: Optional[FlightWaypoint] = None + self.targets = targets @property def is_helo(self) -> bool: return getattr(self.flight.unit_type, "helicopter", False) - def build(self) -> List[FlightWaypoint]: - return self.waypoints + @staticmethod + def takeoff(departure: ControlPoint) -> FlightWaypoint: + """Create takeoff waypoint for the given arrival airfield or carrier. - def ascent(self, departure: ControlPoint) -> None: + Note that the takeoff waypoint will automatically be created by pydcs + when we create the group, but creating our own before generation makes + the planning code simpler. + + Args: + departure: Departure airfield or carrier. + """ + position = departure.position + waypoint = FlightWaypoint( + FlightWaypointType.TAKEOFF, + position.x, + position.y, + 0 + ) + waypoint.name = "TAKEOFF" + waypoint.alt_type = "RADIO" + waypoint.description = "Takeoff" + waypoint.pretty_name = "Takeoff" + return waypoint + + def ascent(self, departure: ControlPoint) -> FlightWaypoint: """Create ascent waypoint for the given departure airfield or carrier. Args: @@ -49,9 +77,9 @@ class WaypointBuilder: waypoint.alt_type = "RADIO" waypoint.description = "Ascend" waypoint.pretty_name = "Ascend" - self.waypoints.append(waypoint) + return waypoint - def descent(self, arrival: ControlPoint) -> None: + def descent(self, arrival: ControlPoint) -> FlightWaypoint: """Create descent waypoint for the given arrival airfield or carrier. Args: @@ -73,9 +101,10 @@ class WaypointBuilder: waypoint.alt_type = "RADIO" waypoint.description = "Descend to pattern altitude" waypoint.pretty_name = "Descend" - self.waypoints.append(waypoint) + return waypoint - def land(self, arrival: ControlPoint) -> None: + @staticmethod + def land(arrival: ControlPoint) -> FlightWaypoint: """Create descent waypoint for the given arrival airfield or carrier. Args: @@ -92,9 +121,9 @@ class WaypointBuilder: waypoint.alt_type = "RADIO" waypoint.description = "Land" waypoint.pretty_name = "Land" - self.waypoints.append(waypoint) + return waypoint - def hold(self, position: Point) -> None: + def hold(self, position: Point) -> FlightWaypoint: waypoint = FlightWaypoint( FlightWaypointType.LOITER, position.x, @@ -104,9 +133,9 @@ class WaypointBuilder: waypoint.pretty_name = "Hold" waypoint.description = "Wait until push time" waypoint.name = "HOLD" - self.waypoints.append(waypoint) + return waypoint - def join(self, position: Point) -> None: + def join(self, position: Point) -> FlightWaypoint: waypoint = FlightWaypoint( FlightWaypointType.JOIN, position.x, @@ -116,9 +145,9 @@ class WaypointBuilder: waypoint.pretty_name = "Join" waypoint.description = "Rendezvous with package" waypoint.name = "JOIN" - self.waypoints.append(waypoint) + return waypoint - def split(self, position: Point) -> None: + def split(self, position: Point) -> FlightWaypoint: waypoint = FlightWaypoint( FlightWaypointType.SPLIT, position.x, @@ -128,25 +157,30 @@ class WaypointBuilder: waypoint.pretty_name = "Split" waypoint.description = "Depart from package" waypoint.name = "SPLIT" - self.waypoints.append(waypoint) + return waypoint - def ingress_cas(self, position: Point, objective: MissionTarget) -> None: - self._ingress(FlightWaypointType.INGRESS_CAS, position, objective) + def ingress_cas(self, position: Point, + objective: MissionTarget) -> FlightWaypoint: + return self._ingress(FlightWaypointType.INGRESS_CAS, position, + objective) - def ingress_escort(self, position: Point, objective: MissionTarget) -> None: - self._ingress(FlightWaypointType.INGRESS_ESCORT, position, objective) + def ingress_escort(self, position: Point, + objective: MissionTarget) -> FlightWaypoint: + return self._ingress(FlightWaypointType.INGRESS_ESCORT, position, + objective) - def ingress_sead(self, position: Point, objective: MissionTarget) -> None: - self._ingress(FlightWaypointType.INGRESS_SEAD, position, objective) + def ingress_sead(self, position: Point, + objective: MissionTarget) -> FlightWaypoint: + return self._ingress(FlightWaypointType.INGRESS_SEAD, position, + objective) - def ingress_strike(self, position: Point, objective: MissionTarget) -> None: - self._ingress(FlightWaypointType.INGRESS_STRIKE, position, objective) + def ingress_strike(self, position: Point, + objective: MissionTarget) -> FlightWaypoint: + return self._ingress(FlightWaypointType.INGRESS_STRIKE, position, + objective) def _ingress(self, ingress_type: FlightWaypointType, position: Point, - objective: MissionTarget) -> None: - if self.ingress_point is not None: - raise RuntimeError("A flight plan can have only one ingress point.") - + objective: MissionTarget) -> FlightWaypoint: waypoint = FlightWaypoint( ingress_type, position.x, @@ -156,10 +190,11 @@ class WaypointBuilder: waypoint.pretty_name = "INGRESS on " + objective.name waypoint.description = "INGRESS on " + objective.name waypoint.name = "INGRESS" - self.waypoints.append(waypoint) - self.ingress_point = waypoint + # TODO: This seems wrong, but it's what was there before. + waypoint.targets.append(objective) + return waypoint - def egress(self, position: Point, target: MissionTarget) -> None: + def egress(self, position: Point, target: MissionTarget) -> FlightWaypoint: waypoint = FlightWaypoint( FlightWaypointType.EGRESS, position.x, @@ -169,68 +204,45 @@ class WaypointBuilder: waypoint.pretty_name = "EGRESS from " + target.name waypoint.description = "EGRESS from " + target.name waypoint.name = "EGRESS" - self.waypoints.append(waypoint) + return waypoint - def dead_point(self, target: Union[TheaterGroundObject, Unit], name: str, - location: MissionTarget) -> None: - self._target_point(target, name, f"STRIKE {name}", location) - # TODO: Seems fishy. - if self.ingress_point is not None: - self.ingress_point.targetGroup = location + def dead_point(self, target: StrikeTarget) -> FlightWaypoint: + return self._target_point(target, f"STRIKE {target.name}") - def sead_point(self, target: Union[TheaterGroundObject, Unit], name: str, - location: MissionTarget) -> None: - self._target_point(target, name, f"STRIKE {name}", location) - # TODO: Seems fishy. - if self.ingress_point is not None: - self.ingress_point.targetGroup = location + def sead_point(self, target: StrikeTarget) -> FlightWaypoint: + return self._target_point(target, f"STRIKE {target.name}") - def strike_point(self, target: Union[TheaterGroundObject, Unit], name: str, - location: MissionTarget) -> None: - self._target_point(target, name, f"STRIKE {name}", location) - - def _target_point(self, target: Union[TheaterGroundObject, Unit], name: str, - description: str, location: MissionTarget) -> None: - if self.ingress_point is None: - raise RuntimeError( - "An ingress point must be added before target points." - ) + def strike_point(self, target: StrikeTarget) -> FlightWaypoint: + return self._target_point(target, f"STRIKE {target.name}") + @staticmethod + def _target_point(target: StrikeTarget, description: str) -> FlightWaypoint: waypoint = FlightWaypoint( FlightWaypointType.TARGET_POINT, - target.position.x, - target.position.y, + target.target.position.x, + target.target.position.y, 0 ) waypoint.description = description waypoint.pretty_name = description - waypoint.name = name + waypoint.name = target.name # The target waypoints are only for the player's benefit. AI tasks for # the target are set on the ingress point so they begin their attack # *before* reaching the target. waypoint.only_for_player = True - self.waypoints.append(waypoint) - # TODO: This seems wrong, but it's what was there before. - self.ingress_point.targets.append(location) + return waypoint - def sead_area(self, target: MissionTarget) -> None: - self._target_area(f"SEAD on {target.name}", target) - # TODO: Seems fishy. - if self.ingress_point is not None: - self.ingress_point.targetGroup = target + def strike_area(self, target: MissionTarget) -> FlightWaypoint: + return self._target_area(f"STRIKE {target.name}", target) - def dead_area(self, target: MissionTarget) -> None: - self._target_area(f"DEAD on {target.name}", target) - # TODO: Seems fishy. - if self.ingress_point is not None: - self.ingress_point.targetGroup = target + def sead_area(self, target: MissionTarget) -> FlightWaypoint: + return self._target_area(f"SEAD on {target.name}", target) - def _target_area(self, name: str, location: MissionTarget) -> None: - if self.ingress_point is None: - raise RuntimeError( - "An ingress point must be added before target points." - ) + def dead_area(self, target: MissionTarget) -> FlightWaypoint: + return self._target_area(f"DEAD on {target.name}", target) + @staticmethod + def _target_area(name: str, location: MissionTarget) -> FlightWaypoint: waypoint = FlightWaypoint( FlightWaypointType.TARGET_GROUP_LOC, location.position.x, @@ -244,11 +256,9 @@ class WaypointBuilder: # the target are set on the ingress point so they begin their attack # *before* reaching the target. waypoint.only_for_player = True - self.waypoints.append(waypoint) - # TODO: This seems wrong, but it's what was there before. - self.ingress_point.targets.append(location) + return waypoint - def cas(self, position: Point) -> None: + def cas(self, position: Point) -> FlightWaypoint: waypoint = FlightWaypoint( FlightWaypointType.CAS, position.x, @@ -259,9 +269,10 @@ class WaypointBuilder: waypoint.description = "Provide CAS" waypoint.name = "CAS" waypoint.pretty_name = "CAS" - self.waypoints.append(waypoint) + return waypoint - def race_track_start(self, position: Point, altitude: int) -> None: + @staticmethod + def race_track_start(position: Point, altitude: int) -> FlightWaypoint: """Creates a racetrack start waypoint. Args: @@ -277,9 +288,10 @@ class WaypointBuilder: waypoint.name = "RACETRACK START" waypoint.description = "Orbit between this point and the next point" waypoint.pretty_name = "Race-track start" - self.waypoints.append(waypoint) + return waypoint - def race_track_end(self, position: Point, altitude: int) -> None: + @staticmethod + def race_track_end(position: Point, altitude: int) -> FlightWaypoint: """Creates a racetrack end waypoint. Args: @@ -295,9 +307,10 @@ class WaypointBuilder: waypoint.name = "RACETRACK END" waypoint.description = "Orbit between this point and the previous point" waypoint.pretty_name = "Race-track end" - self.waypoints.append(waypoint) + return waypoint - def race_track(self, start: Point, end: Point, altitude: int) -> None: + def race_track(self, start: Point, end: Point, + altitude: int) -> Tuple[FlightWaypoint, FlightWaypoint]: """Creates two waypoint for a racetrack orbit. Args: @@ -305,20 +318,20 @@ class WaypointBuilder: end: The ending racetrack waypoint. altitude: The racetrack altitude. """ - self.race_track_start(start, altitude) - self.race_track_end(end, altitude) + return (self.race_track_start(start, altitude), + self.race_track_end(end, altitude)) - def rtb(self, arrival: ControlPoint) -> None: + def rtb(self, + arrival: ControlPoint) -> Tuple[FlightWaypoint, FlightWaypoint]: """Creates descent ant landing waypoints for the given control point. Args: arrival: Arrival airfield or carrier. """ - self.descent(arrival) - self.land(arrival) + return self.descent(arrival), self.land(arrival) - def escort(self, ingress: Point, target: MissionTarget, - egress: Point) -> None: + def escort(self, ingress: Point, target: MissionTarget, egress: Point) -> \ + Tuple[FlightWaypoint, FlightWaypoint, FlightWaypoint]: """Creates the waypoints needed to escort the package. Args: @@ -332,7 +345,8 @@ class WaypointBuilder: # description in gen.aircraft.JoinPointBuilder), so instead we give # the escort flights a flight plan including the ingress point, target # area, and egress point. - self._ingress(FlightWaypointType.INGRESS_ESCORT, ingress, target) + ingress = self._ingress(FlightWaypointType.INGRESS_ESCORT, ingress, + target) waypoint = FlightWaypoint( FlightWaypointType.TARGET_GROUP_LOC, @@ -343,6 +357,6 @@ class WaypointBuilder: waypoint.name = "TARGET" waypoint.description = "Escort the package" waypoint.pretty_name = "Target area" - self.waypoints.append(waypoint) - self.egress(egress, target) + egress = self.egress(egress, target) + return ingress, waypoint, egress diff --git a/gen/kneeboard.py b/gen/kneeboard.py index 9cf9b258..e22d8e03 100644 --- a/gen/kneeboard.py +++ b/gen/kneeboard.py @@ -157,10 +157,10 @@ class FlightPlanBuilder: self._format_time(waypoint.waypoint.departure_time), ]) - def _format_time(self, time: Optional[int]) -> str: + def _format_time(self, time: Optional[datetime.timedelta]) -> str: if time is None: return "" - local_time = self.start_time + datetime.timedelta(seconds=time) + local_time = self.start_time + time return local_time.strftime(f"%H:%M:%S") def _waypoint_distance(self, waypoint: FlightWaypoint) -> str: @@ -189,8 +189,8 @@ class FlightPlanBuilder: distance = meter_to_nm(self.last_waypoint.position.distance_to_point( waypoint.position )) - duration = (waypoint.tot - last_time) / 3600 - return f"{int(distance / duration)} kt" + duration = waypoint.tot - last_time + return f"{int(distance / duration.total_seconds())} kt" def build(self) -> List[List[str]]: return self.rows diff --git a/qt_ui/models.py b/qt_ui/models.py index e13ac0ea..0d8bf45a 100644 --- a/qt_ui/models.py +++ b/qt_ui/models.py @@ -162,7 +162,7 @@ class PackageModel(QAbstractListModel): """Returns the flight located at the given index.""" return self.package.flights[index.row()] - def update_tot(self, tot: int) -> None: + def update_tot(self, tot: datetime.timedelta) -> None: self.package.time_over_target = tot self.layoutChanged.emit() diff --git a/qt_ui/widgets/QTopPanel.py b/qt_ui/widgets/QTopPanel.py index 7b7f3ec6..79f4e77e 100644 --- a/qt_ui/widgets/QTopPanel.py +++ b/qt_ui/widgets/QTopPanel.py @@ -126,7 +126,7 @@ class QTopPanel(QFrame): continue estimator = TotEstimator(package) for flight in package.flights: - if estimator.mission_start_time(flight) < 0: + if estimator.mission_start_time(flight).total_seconds() < 0: packages.append(package) break return packages diff --git a/qt_ui/widgets/ato.py b/qt_ui/widgets/ato.py index 5f22b961..897a1cec 100644 --- a/qt_ui/widgets/ato.py +++ b/qt_ui/widgets/ato.py @@ -64,7 +64,8 @@ class FlightDelegate(QStyledItemDelegate): count = flight.count name = db.unit_type_name(flight.unit_type) estimator = TotEstimator(self.package) - delay = datetime.timedelta(seconds=estimator.mission_start_time(flight)) + delay = datetime.timedelta( + seconds=int(estimator.mission_start_time(flight).total_seconds())) return f"[{task}] {count} x {name} in {delay}" def second_row_text(self, index: QModelIndex) -> str: @@ -328,10 +329,9 @@ class PackageDelegate(QStyledItemDelegate): def right_text(self, index: QModelIndex) -> str: package = self.package(index) - if package.time_over_target is None: - return "" - tot = datetime.timedelta(seconds=package.time_over_target) - return f"TOT T+{tot}" + delay = datetime.timedelta( + seconds=int(package.time_over_target.total_seconds())) + return f"TOT T+{delay}" def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex) -> None: diff --git a/qt_ui/widgets/combos/QPredefinedWaypointSelectionComboBox.py b/qt_ui/widgets/combos/QPredefinedWaypointSelectionComboBox.py index 079aab99..aac3b3c4 100644 --- a/qt_ui/widgets/combos/QPredefinedWaypointSelectionComboBox.py +++ b/qt_ui/widgets/combos/QPredefinedWaypointSelectionComboBox.py @@ -1,9 +1,8 @@ -from PySide2.QtCore import QSortFilterProxyModel, Qt, QModelIndex from PySide2.QtGui import QStandardItem, QStandardItemModel -from PySide2.QtWidgets import QComboBox, QCompleter + from game import Game from gen import Conflict, FlightWaypointType -from gen.flights.flight import FlightWaypoint, PredefinedWaypointCategory +from gen.flights.flight import FlightWaypoint from qt_ui.widgets.combos.QFilteredComboBox import QFilteredComboBox from theater import ControlPointType @@ -66,8 +65,6 @@ class QPredefinedWaypointSelectionComboBox(QFilteredComboBox): wpt.alt_type = "RADIO" wpt.pretty_name = wpt.name wpt.description = "Frontline" - wpt.data = [cp, ecp] - wpt.category = PredefinedWaypointCategory.FRONTLINE i = add_model_item(i, model, wpt.pretty_name, wpt) if self.include_targets: @@ -86,13 +83,10 @@ class QPredefinedWaypointSelectionComboBox(QFilteredComboBox): wpt.pretty_name = wpt.name wpt.obj_name = ground_object.obj_name wpt.targets.append(ground_object) - wpt.data = ground_object if cp.captured: wpt.description = "Friendly Building" - wpt.category = PredefinedWaypointCategory.ALLY_BUILDING else: wpt.description = "Enemy Building" - wpt.category = PredefinedWaypointCategory.ENEMY_BUILDING i = add_model_item(i, model, wpt.pretty_name, wpt) if self.include_units: @@ -112,15 +106,12 @@ class QPredefinedWaypointSelectionComboBox(QFilteredComboBox): wpt.name = wpt.name = "[" + str(ground_object.obj_name) + "] : " + u.type + " #" + str(j) wpt.pretty_name = wpt.name wpt.targets.append(u) - wpt.data = u wpt.obj_name = ground_object.obj_name wpt.waypoint_type = FlightWaypointType.CUSTOM if cp.captured: wpt.description = "Friendly unit : " + u.type - wpt.category = PredefinedWaypointCategory.ALLY_UNIT else: wpt.description = "Enemy unit : " + u.type - wpt.category = PredefinedWaypointCategory.ENEMY_UNIT i = add_model_item(i, model, wpt.pretty_name, wpt) if self.include_airbases: @@ -134,13 +125,10 @@ class QPredefinedWaypointSelectionComboBox(QFilteredComboBox): ) wpt.alt_type = "RADIO" wpt.name = cp.name - wpt.data = cp if cp.captured: wpt.description = "Position of " + cp.name + " [Friendly Airbase]" - wpt.category = PredefinedWaypointCategory.ALLY_CP else: wpt.description = "Position of " + cp.name + " [Enemy Airbase]" - wpt.category = PredefinedWaypointCategory.ENEMY_CP if cp.cptype == ControlPointType.AIRCRAFT_CARRIER_GROUP: wpt.pretty_name = cp.name + " (Aircraft Carrier Group)" diff --git a/qt_ui/widgets/map/QLiberationMap.py b/qt_ui/widgets/map/QLiberationMap.py index 793cd641..1017619b 100644 --- a/qt_ui/widgets/map/QLiberationMap.py +++ b/qt_ui/widgets/map/QLiberationMap.py @@ -30,9 +30,9 @@ from game.data.aaa_db import AAA_UNITS from game.data.radar_db import UNITS_WITH_RADAR from game.utils import meter_to_feet from game.weather import TimeOfDay -from gen import Conflict, PackageWaypointTiming -from gen.ato import Package +from gen import Conflict from gen.flights.flight import Flight, FlightWaypoint, FlightWaypointType +from gen.flights.flightplan import FlightPlan from qt_ui.displayoptions import DisplayOptions from qt_ui.models import GameModel from qt_ui.widgets.map.QFrontLine import QFrontLine @@ -294,11 +294,10 @@ class QLiberationMap(QGraphicsView): selected = (p_idx, f_idx) == self.selected_flight if DisplayOptions.flight_paths.only_selected and not selected: continue - self.draw_flight_plan(scene, package_model.package, flight, - selected) + self.draw_flight_plan(scene, flight, selected) - def draw_flight_plan(self, scene: QGraphicsScene, package: Package, - flight: Flight, selected: bool) -> None: + def draw_flight_plan(self, scene: QGraphicsScene, flight: Flight, + selected: bool) -> None: is_player = flight.from_cp.captured pos = self._transform_point(flight.from_cp.position) @@ -310,7 +309,7 @@ class QLiberationMap(QGraphicsView): FlightWaypointType.TARGET_POINT, FlightWaypointType.TARGET_SHIP, ) - for idx, point in enumerate(flight.points): + for idx, point in enumerate(flight.flight_plan.waypoints[1:]): new_pos = self._transform_point(Point(point.x, point.y)) self.draw_flight_path(scene, prev_pos, new_pos, is_player, selected) @@ -321,8 +320,8 @@ class QLiberationMap(QGraphicsView): # Don't draw dozens of targets over each other. continue drew_target = True - self.draw_waypoint_info(scene, idx + 1, point, new_pos, package, - flight) + self.draw_waypoint_info(scene, idx + 1, point, new_pos, + flight.flight_plan) prev_pos = tuple(new_pos) self.draw_flight_path(scene, prev_pos, pos, is_player, selected) @@ -337,21 +336,21 @@ class QLiberationMap(QGraphicsView): def draw_waypoint_info(self, scene: QGraphicsScene, number: int, waypoint: FlightWaypoint, position: Tuple[int, int], - package: Package, flight: Flight) -> None: - timing = PackageWaypointTiming.for_package(package) + flight_plan: FlightPlan) -> None: altitude = meter_to_feet(waypoint.alt) altitude_type = "AGL" if waypoint.alt_type == "RADIO" else "MSL" prefix = "TOT" - time = timing.tot_for_waypoint(flight, waypoint) + time = flight_plan.tot_for_waypoint(waypoint) if time is None: prefix = "Depart" - time = timing.depart_time_for_waypoint(waypoint, flight) + time = flight_plan.depart_time_for_waypoint(waypoint) if time is None: tot = "" else: - tot = f"{prefix} T+{datetime.timedelta(seconds=time)}" + time = datetime.timedelta(seconds=int(time.total_seconds())) + tot = f"{prefix} T+{time}" pen = QPen(QColor("black"), 0.3) brush = QColor("white") diff --git a/qt_ui/windows/mission/QPackageDialog.py b/qt_ui/windows/mission/QPackageDialog.py index caa017cd..850ddd2e 100644 --- a/qt_ui/windows/mission/QPackageDialog.py +++ b/qt_ui/windows/mission/QPackageDialog.py @@ -1,5 +1,6 @@ """Dialogs for creating and editing ATO packages.""" import logging +from datetime import timedelta from typing import Optional from PySide2.QtCore import QItemSelection, QTime, Signal @@ -118,7 +119,7 @@ class QPackageDialog(QDialog): return self.game_model.game def tot_qtime(self) -> QTime: - delay = self.package_model.package.time_over_target + delay = int(self.package_model.package.time_over_target.total_seconds()) hours = delay // 3600 minutes = delay // 60 % 60 seconds = delay % 60 @@ -137,11 +138,11 @@ class QPackageDialog(QDialog): def save_tot(self) -> None: time = self.tot_spinner.time() seconds = time.hour() * 3600 + time.minute() * 60 + time.second() - self.package_model.update_tot(seconds) + self.package_model.update_tot(timedelta(seconds=seconds)) def reset_tot(self) -> None: if not list(self.package_model.flights): - self.package_model.update_tot(0) + self.package_model.update_tot(timedelta()) else: self.package_model.update_tot( TotEstimator(self.package_model.package).earliest_tot()) diff --git a/qt_ui/windows/mission/flight/settings/QFlightDepartureDisplay.py b/qt_ui/windows/mission/flight/settings/QFlightDepartureDisplay.py index a720cc8b..982a1fbb 100644 --- a/qt_ui/windows/mission/flight/settings/QFlightDepartureDisplay.py +++ b/qt_ui/windows/mission/flight/settings/QFlightDepartureDisplay.py @@ -19,7 +19,8 @@ class QFlightDepartureDisplay(QGroupBox): layout.addLayout(departure_row) estimator = TotEstimator(package) - delay = datetime.timedelta(seconds=estimator.mission_start_time(flight)) + delay = datetime.timedelta( + seconds=int(estimator.mission_start_time(flight).total_seconds())) departure_row.addWidget(QLabel( f"Departing from {flight.from_cp.name}" diff --git a/qt_ui/windows/mission/flight/waypoints/QFlightWaypointList.py b/qt_ui/windows/mission/flight/waypoints/QFlightWaypointList.py index 99dcc410..4b472084 100644 --- a/qt_ui/windows/mission/flight/waypoints/QFlightWaypointList.py +++ b/qt_ui/windows/mission/flight/waypoints/QFlightWaypointList.py @@ -1,4 +1,3 @@ -import datetime import itertools from PySide2.QtCore import QItemSelectionModel, QPoint @@ -6,7 +5,6 @@ from PySide2.QtGui import QStandardItem, QStandardItemModel from PySide2.QtWidgets import QHeaderView, QTableView from game.utils import meter_to_feet -from gen.aircraft import PackageWaypointTiming from gen.ato import Package from gen.flights.flight import Flight, FlightWaypoint from qt_ui.windows.mission.flight.waypoints.QFlightWaypointItem import \ @@ -43,8 +41,6 @@ class QFlightWaypointList(QTableView): self.model.setHorizontalHeaderLabels(["Name", "Alt", "TOT/DEPART"]) - timing = PackageWaypointTiming.for_package(self.package) - # The first waypoint is set up by pydcs at mission generation time, so # we need to add that waypoint manually. takeoff = FlightWaypoint(self.flight.from_cp.position.x, @@ -55,13 +51,12 @@ class QFlightWaypointList(QTableView): waypoints = itertools.chain([takeoff], self.flight.points) for row, waypoint in enumerate(waypoints): - self.add_waypoint_row(row, self.flight, waypoint, timing) + self.add_waypoint_row(row, self.flight, waypoint) self.selectionModel().setCurrentIndex(self.indexAt(QPoint(1, 1)), QItemSelectionModel.Select) def add_waypoint_row(self, row: int, flight: Flight, - waypoint: FlightWaypoint, - timing: PackageWaypointTiming) -> None: + waypoint: FlightWaypoint) -> None: self.model.insertRow(self.model.rowCount()) self.model.setItem(row, 0, QWaypointItem(waypoint, row)) @@ -72,18 +67,18 @@ class QFlightWaypointList(QTableView): altitude_item.setEditable(False) self.model.setItem(row, 1, altitude_item) - tot = self.tot_text(flight, waypoint, timing) + tot = self.tot_text(flight, waypoint) tot_item = QStandardItem(tot) tot_item.setEditable(False) self.model.setItem(row, 2, tot_item) - def tot_text(self, flight: Flight, waypoint: FlightWaypoint, - timing: PackageWaypointTiming) -> str: + @staticmethod + def tot_text(flight: Flight, waypoint: FlightWaypoint) -> str: prefix = "" - time = timing.tot_for_waypoint(flight, waypoint) + time = flight.flight_plan.tot_for_waypoint(waypoint) if time is None: prefix = "Depart " - time = timing.depart_time_for_waypoint(waypoint, self.flight) + time = flight.flight_plan.depart_time_for_waypoint(waypoint) if time is None: return "" - return f"{prefix}T+{datetime.timedelta(seconds=time)}" + return f"{prefix}T+{time}"