Reorganize flight planning.

Previously we were trying to make every potential flight plan look
just like a strike mission's flight plan. This led to a lot of special
case behavior in several places that was causing us to misplan TOTs.

I've reorganized this such that there's now an explicit `FlightPlan`
class, and any specialized behavior is handled by the subclasses.

I've also taken the opportunity to alter the behavior of CAS and
front-line CAP missions. These no longer involve the usual formation
waypoints. Instead the CAP will aim to be on station at the time that
the CAS mission reaches its ingress point, and leave at its egress
time. Both flights fly directly to the point with a start time
configured for a rendezvous.

It might be worth adding hold points back to every flight plan just to
ensure that non-formation flights don't end up with a very low speed
enroute to the target if they perform ground ops quicker than
expected.
This commit is contained in:
Dan Albert 2020-10-30 15:54:20 -07:00
parent d94c57afd6
commit 88b9ed29ba
17 changed files with 1033 additions and 669 deletions

View File

@ -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),
)

View File

@ -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

View File

@ -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)

View File

@ -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.

View File

@ -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

View File

@ -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:
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.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)
if task == FlightType.BARCAP:
return self.generate_barcap(flight)
elif task == FlightType.CAS:
self.generate_cas(flight)
return 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")
return self.generate_sead(flight, custom_targets)
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")
return self.generate_escort(flight)
elif task == FlightType.SEAD:
self.generate_sead(flight, custom_targets)
return self.generate_sead(flight, custom_targets)
elif task == FlightType.STRIKE:
self.generate_strike(flight)
return self.generate_strike(flight)
elif task == FlightType.TARCAP:
self.generate_frontline_cap(flight)
return 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:
logging.exception(f"Could not create flight plan")
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)

View File

@ -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
})
time_to_target = self.travel_time_to_target(flight)
if time_to_target is None:
logging.warning(
f"Found no race track. Cannot estimate TOT for {flight}")
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 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,
])
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
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
return timedelta(minutes=5)
@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
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)
@staticmethod
def travel_time_to_rendezvous_or_target(
flight: Flight) -> Optional[timedelta]:
if flight.flight_plan is None:
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)
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

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -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:

View File

@ -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)"

View File

@ -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")

View File

@ -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())

View File

@ -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 <b>{flight.from_cp.name}</b>"

View File

@ -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}"