diff --git a/.gitignore b/.gitignore
index 8c7d79f3..1bf595f6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,7 +16,7 @@ tests/**
/liberation_preferences.json
/state.json
-logs/liberation.log
+logs/
qt_ui/logs/liberation.log
diff --git a/gen/aircraft.py b/gen/aircraft.py
index f9cd63f1..cbb028fe 100644
--- a/gen/aircraft.py
+++ b/gen/aircraft.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
import logging
import random
from dataclasses import dataclass
@@ -28,7 +30,7 @@ from dcs.planes import (
SpitfireLFMkIXCW,
Su_33,
)
-from dcs.point import PointAction
+from dcs.point import MovingPoint, PointAction
from dcs.task import (
AntishipStrike,
AttackGroup,
@@ -40,8 +42,6 @@ from dcs.task import (
EngageTargets,
Escort,
GroundAttack,
- MainTask,
- NoTask,
OptROE,
OptRTBOnBingoFuel,
OptRTBOnOutOfAmmo,
@@ -64,10 +64,10 @@ from dcs.unittype import FlyingType, UnitType
from game import db
from game.data.cap_capabilities_db import GUNFIGHTERS
from game.settings import Settings
-from game.utils import nm_to_meter
+from game.utils import meter_to_nm, nm_to_meter
from gen.airfields import RunwayData
from gen.airsupportgen import AirSupport
-from gen.ato import AirTaskingOrder
+from gen.ato import AirTaskingOrder, Package
from gen.callsigns import create_group_callsign_from_unit
from gen.flights.flight import (
Flight,
@@ -76,9 +76,10 @@ from gen.flights.flight import (
FlightWaypointType,
)
from gen.radios import MHz, Radio, RadioFrequency, RadioRegistry, get_radio
+from theater import MissionTarget, TheaterGroundObject
from theater.controlpoint import ControlPoint, ControlPointType
-from .naming import namegen
from .conflictgen import Conflict
+from .naming import namegen
WARM_START_HELI_AIRSPEED = 120
WARM_START_HELI_ALT = 500
@@ -532,6 +533,148 @@ AIRCRAFT_DATA["P-51D-30-NA"] = AIRCRAFT_DATA["P-51D"]
AIRCRAFT_DATA["P-47D-30"] = AIRCRAFT_DATA["P-51D"]
+@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
+
+ @property
+ def race_track_start(self) -> Optional[int]:
+ cap_types = (FlightType.BARCAP, FlightType.CAP)
+ if self.package.primary_task in cap_types:
+ # CAP flights don't have hold points, and we don't calculate takeoff
+ # times yet or adjust the TOT based on when the flight can arrive,
+ # so if we set a TOT that gives the flight a lot of extra time it
+ # will just fly to the start point slowly, possibly slowly enough to
+ # stall and crash. Just don't set a TOT for these points and let the
+ # CAP get on station ASAP.
+ return None
+ else:
+ return self.ingress
+
+ @property
+ def race_track_end(self) -> int:
+ cap_types = (FlightType.BARCAP, FlightType.CAP)
+ if self.package.primary_task in cap_types:
+ return self.target + CAP_DURATION * 60
+ else:
+ return self.egress
+
+ def push_time(self, flight: Flight, hold_point: Point) -> int:
+ assert self.package.waypoints is not None
+ return self.join - self.travel_time(
+ hold_point,
+ self.package.waypoints.join,
+ self.flight_ground_speed(flight)
+ )
+
+ def tot_for_waypoint(self, waypoint: FlightWaypoint) -> Optional[int]:
+ target_types = (
+ FlightWaypointType.TARGET_GROUP_LOC,
+ FlightWaypointType.TARGET_POINT,
+ FlightWaypointType.TARGET_SHIP,
+ )
+
+ ingress_types = (
+ FlightWaypointType.INGRESS_CAS,
+ FlightWaypointType.INGRESS_SEAD,
+ FlightWaypointType.INGRESS_STRIKE,
+ )
+
+ 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
+ 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, Point(waypoint.x, waypoint.y))
+ elif waypoint.waypoint_type == FlightWaypointType.PATROL:
+ return self.race_track_end
+ return None
+
+ @classmethod
+ def for_package(cls, package: Package) -> PackageWaypointTiming:
+ assert package.time_over_target is not None
+ assert package.waypoints is not None
+
+ group_ground_speed = cls.package_ground_speed(package)
+
+ ingress = package.time_over_target - cls.travel_time(
+ package.waypoints.ingress,
+ package.target.position,
+ group_ground_speed
+ )
+
+ join = ingress - cls.travel_time(
+ package.waypoints.join,
+ package.waypoints.ingress,
+ group_ground_speed
+ )
+
+ egress = package.time_over_target + cls.travel_time(
+ package.target.position,
+ package.waypoints.egress,
+ group_ground_speed
+ )
+
+ split = egress + cls.travel_time(
+ package.waypoints.egress,
+ package.waypoints.split,
+ group_ground_speed
+ )
+
+ return cls(package, join, ingress, egress, split)
+
+ @classmethod
+ def package_ground_speed(cls, package: Package) -> int:
+ speeds = []
+ for flight in package.flights:
+ speeds.append(cls.flight_ground_speed(flight))
+ return min(speeds) # knots
+
+ @staticmethod
+ def flight_ground_speed(_flight: Flight) -> int:
+ # TODO: Gather data so this is useful.
+ return 400 # knots
+
+ @staticmethod
+ def travel_time(a: Point, b: Point, speed: float) -> int:
+ error_factor = 1.1
+ distance = meter_to_nm(a.distance_to_point(b))
+ hours = distance / speed
+ seconds = hours * 3600
+ return int(seconds * error_factor)
+
+
class AircraftConflictGenerator:
def __init__(self, mission: Mission, conflict: Conflict, settings: Settings,
game, radio_registry: RadioRegistry):
@@ -634,7 +777,8 @@ class AircraftConflictGenerator:
arrival=departure_runway,
# TODO: Support for divert airfields.
divert=None,
- waypoints=[first_point] + flight.points,
+ # Waypoints are added later, after they've had their TOTs set.
+ waypoints=[],
intra_flight_channel=channel
))
@@ -817,6 +961,7 @@ class AircraftConflictGenerator:
self.clear_parking_slots()
for package in ato.packages:
+ 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:
@@ -825,8 +970,7 @@ class AircraftConflictGenerator:
logging.info(f"Generating flight: {flight.unit_type}")
group = self.generate_planned_flight(flight.from_cp, country,
flight)
- self.setup_flight_group(group, flight, flight.flight_type,
- dynamic_runways)
+ self.setup_flight_group(group, flight, timing, dynamic_runways)
self.setup_group_activation_trigger(flight, group)
def setup_group_activation_trigger(self, flight, group):
@@ -936,134 +1080,334 @@ class AircraftConflictGenerator:
flight.group = group
return group
-
- def setup_flight_group(self, group, flight, flight_type,
- dynamic_runways: Dict[str, RunwayData]):
-
- if flight_type in [FlightType.CAP, FlightType.BARCAP, FlightType.TARCAP, FlightType.INTERCEPTION]:
- group.task = CAP.name
- self._setup_group(group, CAP, flight, dynamic_runways)
- # group.points[0].tasks.clear()
- group.points[0].tasks.clear()
- group.points[0].tasks.append(EngageTargets(max_distance=nm_to_meter(50), targets=[Targets.All.Air]))
- # group.tasks.append(EngageTargets(max_distance=nm_to_meter(120), targets=[Targets.All.Air]))
- if flight.unit_type not in GUNFIGHTERS:
- group.points[0].tasks.append(OptRTBOnOutOfAmmo(OptRTBOnOutOfAmmo.Values.AAM))
- else:
- group.points[0].tasks.append(OptRTBOnOutOfAmmo(OptRTBOnOutOfAmmo.Values.Cannon))
-
- elif flight_type in [FlightType.CAS, FlightType.BAI]:
- group.task = CAS.name
- self._setup_group(group, CAS, flight, dynamic_runways)
- group.points[0].tasks.clear()
- group.points[0].tasks.append(EngageTargets(max_distance=nm_to_meter(10), targets=[Targets.All.GroundUnits.GroundVehicles]))
- group.points[0].tasks.append(OptReactOnThreat(OptReactOnThreat.Values.EvadeFire))
- group.points[0].tasks.append(OptROE(OptROE.Values.OpenFireWeaponFree))
- group.points[0].tasks.append(OptRTBOnOutOfAmmo(OptRTBOnOutOfAmmo.Values.Unguided))
- group.points[0].tasks.append(OptRestrictJettison(True))
- elif flight_type in [FlightType.SEAD, FlightType.DEAD]:
- group.task = SEAD.name
- self._setup_group(group, SEAD, flight, dynamic_runways)
- group.points[0].tasks.clear()
- group.points[0].tasks.append(NoTask())
- group.points[0].tasks.append(OptReactOnThreat(OptReactOnThreat.Values.EvadeFire))
- group.points[0].tasks.append(OptROE(OptROE.Values.OpenFire))
- group.points[0].tasks.append(OptRestrictJettison(True))
- group.points[0].tasks.append(OptRTBOnOutOfAmmo(OptRTBOnOutOfAmmo.Values.ASM))
- elif flight_type in [FlightType.STRIKE]:
- group.task = PinpointStrike.name
- self._setup_group(group, GroundAttack, flight, dynamic_runways)
- group.points[0].tasks.clear()
- group.points[0].tasks.append(OptReactOnThreat(OptReactOnThreat.Values.EvadeFire))
- group.points[0].tasks.append(OptROE(OptROE.Values.OpenFire))
- group.points[0].tasks.append(OptRestrictJettison(True))
- elif flight_type in [FlightType.ANTISHIP]:
- group.task = AntishipStrike.name
- self._setup_group(group, AntishipStrike, flight, dynamic_runways)
- group.points[0].tasks.clear()
- group.points[0].tasks.append(OptReactOnThreat(OptReactOnThreat.Values.EvadeFire))
- group.points[0].tasks.append(OptROE(OptROE.Values.OpenFire))
- group.points[0].tasks.append(OptRestrictJettison(True))
- elif flight_type == FlightType.ESCORT:
- group.task = Escort.name
- self._setup_group(group, Escort, flight, dynamic_runways)
- # TODO: Cleanup duplication...
- group.points[0].tasks.clear()
- group.points[0].tasks.append(OptReactOnThreat(OptReactOnThreat.Values.EvadeFire))
- group.points[0].tasks.append(OptROE(OptROE.Values.OpenFire))
- group.points[0].tasks.append(OptRestrictJettison(True))
+ @staticmethod
+ def configure_behavior(
+ group: FlyingGroup,
+ react_on_threat: Optional[OptReactOnThreat.Values] = None,
+ roe: Optional[OptROE.Values] = None,
+ rtb_winchester: Optional[OptRTBOnOutOfAmmo.Values] = None,
+ restrict_jettison: Optional[bool] = None) -> None:
+ group.points[0].tasks.clear()
+ if react_on_threat is not None:
+ group.points[0].tasks.append(OptReactOnThreat(react_on_threat))
+ if roe is not None:
+ group.points[0].tasks.append(OptROE(roe))
+ if restrict_jettison is not None:
+ group.points[0].tasks.append(OptRestrictJettison(restrict_jettison))
+ if rtb_winchester is not None:
+ group.points[0].tasks.append(OptRTBOnOutOfAmmo(rtb_winchester))
group.points[0].tasks.append(OptRTBOnBingoFuel(True))
group.points[0].tasks.append(OptRestrictAfterburner(True))
+ @staticmethod
+ def configure_eplrs(group: FlyingGroup, flight: Flight) -> None:
if hasattr(flight.unit_type, 'eplrs'):
if flight.unit_type.eplrs:
group.points[0].tasks.append(EPLRS(group.id))
- for i, point in enumerate(flight.points):
- if not point.only_for_player or (point.only_for_player and flight.client_count > 0):
- pt = group.add_waypoint(Point(point.x, point.y), point.alt)
- if point.waypoint_type == FlightWaypointType.PATROL_TRACK:
- action = ControlledTask(OrbitAction(altitude=pt.alt, pattern=OrbitAction.OrbitPattern.RaceTrack))
- action.stop_after_duration(CAP_DURATION * 60)
- #for tgt in point.targets:
- # if hasattr(tgt, "position"):
- # engagetgt = EngageTargetsInZone(tgt.position, radius=CAP_DEFAULT_ENGAGE_DISTANCE, targets=[Targets.All.Air])
- # pt.tasks.append(engagetgt)
- elif point.waypoint_type == FlightWaypointType.LANDING_POINT:
- pt.type = "Land"
- pt.action = PointAction.Landing
- elif point.waypoint_type == FlightWaypointType.INGRESS_STRIKE:
+ def configure_cap(self, group: FlyingGroup, flight: Flight,
+ dynamic_runways: Dict[str, RunwayData]) -> None:
+ group.task = CAP.name
+ self._setup_group(group, CAP, flight, dynamic_runways)
- if group.units[0].unit_type == B_17G:
- if len(point.targets) > 0:
- bcenter = Point(0,0)
- for j, t in enumerate(point.targets):
- bcenter.x += t.position.x
- bcenter.y += t.position.y
- bcenter.x = bcenter.x / len(point.targets)
- bcenter.y = bcenter.y / len(point.targets)
- bombing = Bombing(bcenter)
- bombing.params["expend"] = "All"
- bombing.params["attackQtyLimit"] = False
- bombing.params["directionEnabled"] = False
- bombing.params["altitudeEnabled"] = False
- bombing.params["weaponType"] = 2032
- bombing.params["groupAttack"] = True
- pt.tasks.append(bombing)
- else:
- for j, t in enumerate(point.targets):
- print(t.position)
- pt.tasks.append(Bombing(t.position))
- if group.units[0].unit_type == JF_17 and j < 4:
- group.add_nav_target_point(t.position, "PP" + str(j + 1))
- if group.units[0].unit_type == F_14B and j == 0:
- group.add_nav_target_point(t.position, "ST")
- if group.units[0].unit_type == AJS37 and j < 9:
- group.add_nav_target_point(t.position, "M" + str(j + 1))
- elif point.waypoint_type == FlightWaypointType.INGRESS_SEAD:
+ if flight.unit_type not in GUNFIGHTERS:
+ ammo_type = OptRTBOnOutOfAmmo.Values.AAM
+ else:
+ ammo_type = OptRTBOnOutOfAmmo.Values.Cannon
- tgroup = self.m.find_group(point.targetGroup.group_identifier)
- if tgroup is not None:
- task = AttackGroup(tgroup.id)
- task.params["expend"] = "All"
- task.params["attackQtyLimit"] = False
- task.params["directionEnabled"] = False
- task.params["altitudeEnabled"] = False
- task.params["weaponType"] = 268402702 # Guided Weapons
- task.params["groupAttack"] = True
- pt.tasks.append(task)
+ self.configure_behavior(group, rtb_winchester=ammo_type)
- for j, t in enumerate(point.targets):
- if group.units[0].unit_type == JF_17 and j < 4:
- group.add_nav_target_point(t.position, "PP" + str(j + 1))
- if group.units[0].unit_type == F_14B and j == 0:
- group.add_nav_target_point(t.position, "ST")
- if group.units[0].unit_type == AJS37 and j < 9:
- group.add_nav_target_point(t.position, "M" + str(j + 1))
+ group.points[0].tasks.append(EngageTargets(max_distance=nm_to_meter(50),
+ targets=[Targets.All.Air]))
- if pt is not None:
- pt.alt_type = point.alt_type
- pt.name = String(point.name)
+ def configure_cas(self, group: FlyingGroup, flight: Flight,
+ dynamic_runways: Dict[str, RunwayData]) -> None:
+ group.task = CAS.name
+ self._setup_group(group, CAS, flight, dynamic_runways)
+ self.configure_behavior(
+ group,
+ react_on_threat=OptReactOnThreat.Values.EvadeFire,
+ roe=OptROE.Values.OpenFireWeaponFree,
+ rtb_winchester=OptRTBOnOutOfAmmo.Values.Unguided,
+ restrict_jettison=True)
+ group.points[0].tasks.append(
+ EngageTargets(max_distance=nm_to_meter(10),
+ targets=[Targets.All.GroundUnits.GroundVehicles])
+ )
+ def configure_sead(self, group: FlyingGroup, flight: Flight,
+ dynamic_runways: Dict[str, RunwayData]) -> None:
+ group.task = SEAD.name
+ self._setup_group(group, SEAD, flight, dynamic_runways)
+ self.configure_behavior(
+ group,
+ react_on_threat=OptReactOnThreat.Values.EvadeFire,
+ roe=OptROE.Values.OpenFire,
+ rtb_winchester=OptRTBOnOutOfAmmo.Values.ASM,
+ restrict_jettison=True)
+
+ def configure_strike(self, group: FlyingGroup, flight: Flight,
+ dynamic_runways: Dict[str, RunwayData]) -> None:
+ group.task = PinpointStrike.name
+ self._setup_group(group, GroundAttack, flight, dynamic_runways)
+ self.configure_behavior(
+ group,
+ react_on_threat=OptReactOnThreat.Values.EvadeFire,
+ roe=OptROE.Values.OpenFire,
+ restrict_jettison=True)
+
+ def configure_anti_ship(self, group: FlyingGroup, flight: Flight,
+ dynamic_runways: Dict[str, RunwayData]) -> None:
+ group.task = AntishipStrike.name
+ self._setup_group(group, AntishipStrike, flight, dynamic_runways)
+ self.configure_behavior(
+ group,
+ react_on_threat=OptReactOnThreat.Values.EvadeFire,
+ roe=OptROE.Values.OpenFire,
+ restrict_jettison=True)
+
+ def configure_escort(self, group: FlyingGroup, flight: Flight,
+ dynamic_runways: Dict[str, RunwayData]) -> None:
+ group.task = Escort.name
+ self._setup_group(group, Escort, flight, dynamic_runways)
+ self.configure_behavior(group, roe=OptROE.Values.OpenFire,
+ restrict_jettison=True)
+
+ def configure_unknown_task(self, group: FlyingGroup,
+ flight: Flight) -> None:
+ logging.error(f"Unhandled flight type: {flight.flight_type.name}")
+ self.configure_behavior(group)
+
+ def setup_flight_group(self, group: FlyingGroup, flight: Flight,
+ timing: PackageWaypointTiming,
+ dynamic_runways: Dict[str, RunwayData]) -> None:
+ flight_type = flight.flight_type
+ if flight_type in [FlightType.CAP, FlightType.BARCAP, FlightType.TARCAP,
+ FlightType.INTERCEPTION]:
+ self.configure_cap(group, flight, dynamic_runways)
+ elif flight_type in [FlightType.CAS, FlightType.BAI]:
+ self.configure_cas(group, flight, dynamic_runways)
+ elif flight_type in [FlightType.SEAD, FlightType.DEAD]:
+ self.configure_sead(group, flight, dynamic_runways)
+ elif flight_type in [FlightType.STRIKE]:
+ self.configure_strike(group, flight, dynamic_runways)
+ elif flight_type in [FlightType.ANTISHIP]:
+ self.configure_anti_ship(group, flight, dynamic_runways)
+ elif flight_type == FlightType.ESCORT:
+ self.configure_escort(group, flight, dynamic_runways)
+ else:
+ self.configure_unknown_task(group, flight)
+
+ self.configure_eplrs(group, flight)
+
+ for waypoint in flight.points:
+ waypoint.tot = None
+
+ for point in flight.points:
+ if point.only_for_player and not flight.client_count:
+ continue
+
+ PydcsWaypointBuilder.for_waypoint(
+ point, group, flight, timing, self.m
+ ).build()
+
+ # Set here rather than when the FlightData is created so they waypoints
+ # have their TOTs set.
+ self.flights[-1].waypoints = flight.points
self._setup_custom_payload(flight, group)
+
+
+class PydcsWaypointBuilder:
+ def __init__(self, waypoint: FlightWaypoint, group: FlyingGroup,
+ flight: Flight, timing: PackageWaypointTiming,
+ mission: Mission) -> None:
+ self.waypoint = waypoint
+ self.group = group
+ self.flight = flight
+ self.timing = timing
+ self.mission = mission
+
+ def build(self) -> MovingPoint:
+ waypoint = self.group.add_waypoint(
+ Point(self.waypoint.x, self.waypoint.y), self.waypoint.alt)
+
+ waypoint.alt_type = self.waypoint.alt_type
+ waypoint.name = String(self.waypoint.name)
+ return waypoint
+
+ def set_waypoint_tot(self, waypoint: MovingPoint, tot: int) -> None:
+ self.waypoint.tot = tot
+ waypoint.ETA = tot
+ waypoint.ETA_locked = True
+ waypoint.speed_locked = False
+
+ @classmethod
+ def for_waypoint(cls, waypoint: FlightWaypoint,
+ group: FlyingGroup,
+ flight: Flight,
+ timing: PackageWaypointTiming,
+ mission: Mission) -> PydcsWaypointBuilder:
+ builders = {
+ FlightWaypointType.EGRESS: EgressPointBuilder,
+ 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)
+
+
+class DefaultWaypointBuilder(PydcsWaypointBuilder):
+ pass
+
+
+class HoldPointBuilder(PydcsWaypointBuilder):
+ def build(self) -> MovingPoint:
+ waypoint = super().build()
+ loiter = ControlledTask(OrbitAction(
+ altitude=waypoint.alt,
+ pattern=OrbitAction.OrbitPattern.Circle
+ ))
+ loiter.stop_after_time(
+ self.timing.push_time(self.flight, waypoint.position))
+ waypoint.add_task(loiter)
+ return waypoint
+
+
+class EgressPointBuilder(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 SeadIngressBuilder(IngressBuilder):
+ def build(self) -> MovingPoint:
+ waypoint = super().build()
+
+ target_group = self.waypoint.targetGroup
+ if isinstance(target_group, TheaterGroundObject):
+ tgroup = self.mission.find_group(target_group.group_identifier)
+ if tgroup is not None:
+ task = AttackGroup(tgroup.id)
+ task.params["expend"] = "All"
+ task.params["attackQtyLimit"] = False
+ task.params["directionEnabled"] = False
+ task.params["altitudeEnabled"] = False
+ task.params["weaponType"] = 268402702 # Guided Weapons
+ task.params["groupAttack"] = True
+ waypoint.tasks.append(task)
+
+ for i, t in enumerate(self.waypoint.targets):
+ if self.group.units[0].unit_type == JF_17 and i < 4:
+ self.group.add_nav_target_point(t.position, "PP" + str(i + 1))
+ if self.group.units[0].unit_type == F_14B and i == 0:
+ self.group.add_nav_target_point(t.position, "ST")
+ if self.group.units[0].unit_type == AJS37 and i < 9:
+ self.group.add_nav_target_point(t.position, "M" + str(i + 1))
+ return waypoint
+
+
+class StrikeIngressBuilder(IngressBuilder):
+ def build(self) -> MovingPoint:
+ if self.group.units[0].unit_type == B_17G:
+ return self.build_bombing()
+ else:
+ return self.build_strike()
+
+ def build_bombing(self) -> MovingPoint:
+ waypoint = super().build()
+
+ targets = self.waypoint.targets
+ if not targets:
+ return waypoint
+
+ center = Point(0, 0)
+ for target in targets:
+ center.x += target.position.x
+ center.y += target.position.y
+ center.x /= len(targets)
+ center.y /= len(targets)
+ bombing = Bombing(center)
+ bombing.params["expend"] = "All"
+ bombing.params["attackQtyLimit"] = False
+ bombing.params["directionEnabled"] = False
+ bombing.params["altitudeEnabled"] = False
+ bombing.params["weaponType"] = 2032
+ bombing.params["groupAttack"] = True
+ waypoint.tasks.append(bombing)
+ return waypoint
+
+ def build_strike(self) -> MovingPoint:
+ waypoint = super().build()
+
+ for i, t in enumerate(self.waypoint.targets):
+ waypoint.tasks.append(Bombing(t.position))
+ if self.group.units[0].unit_type == JF_17 and i < 4:
+ self.group.add_nav_target_point(t.position, "PP" + str(i + 1))
+ if self.group.units[0].unit_type == F_14B and i == 0:
+ self.group.add_nav_target_point(t.position, "ST")
+ if self.group.units[0].unit_type == AJS37 and i < 9:
+ self.group.add_nav_target_point(t.position, "M" + str(i + 1))
+ return waypoint
+
+
+class JoinPointBuilder(PydcsWaypointBuilder):
+ def build(self) -> MovingPoint:
+ waypoint = super().build()
+ self.set_waypoint_tot(waypoint, self.timing.join)
+ return waypoint
+
+
+class LandingPointBuilder(PydcsWaypointBuilder):
+ def build(self) -> MovingPoint:
+ waypoint = super().build()
+ waypoint.type = "Land"
+ waypoint.action = PointAction.Landing
+ return waypoint
+
+
+class RaceTrackBuilder(PydcsWaypointBuilder):
+ def build(self) -> MovingPoint:
+ waypoint = super().build()
+
+ racetrack = ControlledTask(OrbitAction(
+ altitude=waypoint.alt,
+ pattern=OrbitAction.OrbitPattern.RaceTrack
+ ))
+
+ start = self.timing.race_track_start
+ if start is not None:
+ self.set_waypoint_tot(waypoint, start)
+ racetrack.stop_after_time(self.timing.race_track_end)
+ 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 ee3a3c55..e9c5393c 100644
--- a/gen/ato.py
+++ b/gen/ato.py
@@ -8,14 +8,15 @@ example, the package to strike an enemy airfield may contain an escort flight,
a SEAD flight, and the strike aircraft themselves. CAP packages may contain only
the single CAP flight.
"""
+import logging
from collections import defaultdict
from dataclasses import dataclass, field
-import logging
-from typing import Dict, Iterator, List, Optional
+from typing import Dict, List, Optional
from dcs.mapping import Point
-from .flights.flight import Flight, FlightType
+
from theater.missiontarget import MissionTarget
+from .flights.flight import Flight, FlightType
@dataclass(frozen=True)
@@ -29,6 +30,14 @@ class Task:
location: str
+@dataclass(frozen=True)
+class PackageWaypoints:
+ join: Point
+ ingress: Point
+ egress: Point
+ split: Point
+
+
@dataclass
class Package:
"""A mission package."""
@@ -42,10 +51,10 @@ class Package:
delay: int = field(default=0)
- join_point: Optional[Point] = field(default=None, init=False, hash=False)
- split_point: Optional[Point] = field(default=None, init=False, hash=False)
- ingress_point: Optional[Point] = field(default=None, init=False, hash=False)
- egress_point: Optional[Point] = field(default=None, init=False, hash=False)
+ #: Desired TOT measured in seconds from mission start.
+ time_over_target: Optional[int] = field(default=None)
+
+ waypoints: Optional[PackageWaypoints] = field(default=None)
def add_flight(self, flight: Flight) -> None:
"""Adds a flight to the package."""
@@ -55,8 +64,7 @@ class Package:
"""Removes a flight from the package."""
self.flights.remove(flight)
if not self.flights:
- self.ingress_point = None
- self.egress_point = None
+ self.waypoints = None
@property
def primary_task(self) -> Optional[FlightType]:
diff --git a/gen/flights/ai_flight_planner.py b/gen/flights/ai_flight_planner.py
index 7bc3fd26..09be3773 100644
--- a/gen/flights/ai_flight_planner.py
+++ b/gen/flights/ai_flight_planner.py
@@ -12,7 +12,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
+from gen import Conflict, PackageWaypointTiming
from gen.ato import Package
from gen.flights.ai_flight_planner_db import (
CAP_CAPABLE,
@@ -496,10 +496,17 @@ class CoalitionMissionPlanner:
latest=90,
margin=5
)
- for package in non_dca_packages:
- package.delay = next(start_time)
- for flight in package.flights:
- flight.scheduled_in = package.delay
+ for package in self.ato.packages:
+ if package.primary_task in dca_types:
+ # All CAP missions should be on station in 15-25 minutes.
+ package.time_over_target = (20 + random.randint(-5, 5)) * 60
+ else:
+ # But other packages should be spread out a bit.
+ package.delay = next(start_time)
+ # TODO: Compute TOT based on package.
+ package.time_over_target = (package.delay + 40) * 60
+ for flight in package.flights:
+ flight.scheduled_in = package.delay
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 90b27ccf..0c972723 100644
--- a/gen/flights/flight.py
+++ b/gen/flights/flight.py
@@ -49,6 +49,7 @@ class FlightWaypointType(Enum):
CUSTOM = 15 # User waypoint (no specific behaviour)
JOIN = 16
SPLIT = 17
+ LOITER = 18
class PredefinedWaypointCategory(Enum):
@@ -66,6 +67,15 @@ class FlightWaypoint:
def __init__(self, waypoint_type: FlightWaypointType, x: float, y: float,
alt: int = 0) -> None:
+ """Creates a flight waypoint.
+
+ Args:
+ waypoint_type: The waypoint type.
+ x: X cooidinate of the waypoint.
+ y: Y coordinate of the waypoint.
+ alt: Altitude of the waypoint. By default this is AGL, but it can be
+ changed to MSL by setting alt_type to "RADIO".
+ """
self.waypoint_type = waypoint_type
self.x = x
self.y = y
@@ -81,6 +91,12 @@ class FlightWaypoint:
self.only_for_player = False
self.data = None
+ # This is 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
+
@classmethod
def from_pydcs(cls, point: MovingPoint,
diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py
index 5e5fefe2..74462c2d 100644
--- a/gen/flights/flightplan.py
+++ b/gen/flights/flightplan.py
@@ -16,10 +16,10 @@ from dcs.unit import Unit
from game.data.doctrine import Doctrine, MODERN_DOCTRINE
from game.utils import nm_to_meter
-from gen.ato import Package
+from gen.ato import Package, PackageWaypoints
from theater import ControlPoint, FrontLine, MissionTarget, TheaterGroundObject
from .closestairfields import ObjectiveDistanceCache
-from .flight import Flight, FlightType, FlightWaypoint, FlightWaypointType
+from .flight import Flight, FlightType, FlightWaypoint
from .waypointbuilder import WaypointBuilder
from ..conflictgen import Conflict
@@ -55,7 +55,8 @@ class FlightPlanBuilder:
"""Creates a default flight plan for the given mission."""
if flight not in self.package.flights:
raise RuntimeError("Flight must be a part of the package")
- self.generate_missing_package_waypoints()
+ if self.package.waypoints is None:
+ self.regenerate_package_waypoints()
# TODO: Flesh out mission types.
try:
@@ -105,15 +106,18 @@ class FlightPlanBuilder:
except InvalidObjectiveLocation as ex:
logging.error(f"Could not create flight plan: {ex}")
- def generate_missing_package_waypoints(self) -> None:
- if self.package.ingress_point is None:
- self.package.ingress_point = self._ingress_point()
- if self.package.egress_point is None:
- self.package.egress_point = self._egress_point()
- if self.package.join_point is None:
- self.package.join_point = self._join_point()
- if self.package.split_point is None:
- self.package.split_point = self._split_point()
+ def regenerate_package_waypoints(self) -> None:
+ ingress_point = self._ingress_point()
+ egress_point = self._egress_point()
+ join_point = self._join_point(ingress_point)
+ split_point = self._split_point(egress_point)
+
+ self.package.waypoints = PackageWaypoints(
+ join_point,
+ ingress_point,
+ egress_point,
+ split_point,
+ )
def generate_strike(self, flight: Flight) -> None:
"""Generates a strike flight plan.
@@ -121,6 +125,7 @@ class FlightPlanBuilder:
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.
@@ -129,8 +134,9 @@ class FlightPlanBuilder:
builder = WaypointBuilder(self.doctrine)
builder.ascent(flight.from_cp)
- builder.join(self.package.join_point)
- builder.ingress_strike(self.package.ingress_point, location)
+ builder.hold(self._hold_point(flight))
+ builder.join(self.package.waypoints.join)
+ builder.ingress_strike(self.package.waypoints.ingress, location)
if len(location.groups) > 0 and location.dcs_identifier == "AA":
# TODO: Replace with DEAD?
@@ -157,8 +163,8 @@ class FlightPlanBuilder:
location
)
- builder.egress(self.package.egress_point, location)
- builder.split(self.package.split_point)
+ builder.egress(self.package.waypoints.egress, location)
+ builder.split(self.package.waypoints.split)
builder.rtb(flight.from_cp)
flight.points = builder.build()
@@ -215,6 +221,7 @@ class FlightPlanBuilder:
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):
@@ -246,7 +253,10 @@ class FlightPlanBuilder:
# Create points
builder = WaypointBuilder(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()
@@ -258,6 +268,7 @@ class FlightPlanBuilder:
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):
@@ -268,21 +279,15 @@ class FlightPlanBuilder:
builder = WaypointBuilder(self.doctrine)
builder.ascent(flight.from_cp)
- builder.join(self.package.join_point)
- builder.ingress_sead(self.package.ingress_point, location)
+ 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:
for target in custom_targets:
- point = FlightWaypoint(
- FlightWaypointType.TARGET_POINT,
- target.position.x,
- target.position.y,
- 0
- )
- point.alt_type = "RADIO"
if flight.flight_type == FlightType.DEAD:
builder.dead_point(target, location.name, location)
else:
@@ -293,17 +298,22 @@ class FlightPlanBuilder:
else:
builder.sead_area(location)
- builder.egress(self.package.egress_point, location)
- builder.split(self.package.split_point)
+ builder.egress(self.package.waypoints.egress, location)
+ builder.split(self.package.waypoints.split)
builder.rtb(flight.from_cp)
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:
- # TODO: Decide common waypoints for the package ahead of time.
- # Packages should determine some common points like push, ingress,
- # egress, and split points ahead of time so they can be shared by all
- # flights.
+ assert self.package.waypoints is not None
patrol_alt = random.randint(
self.doctrine.min_patrol_altitude,
@@ -312,13 +322,11 @@ class FlightPlanBuilder:
builder = WaypointBuilder(self.doctrine)
builder.ascent(flight.from_cp)
- builder.join(self.package.join_point)
- builder.race_track(
- self.package.ingress_point,
- self.package.egress_point,
- patrol_alt
- )
- builder.split(self.package.split_point)
+ builder.hold(self._hold_point(flight))
+ builder.join(self.package.waypoints.join)
+ builder.race_track(self.package.waypoints.ingress,
+ self.package.waypoints.egress, patrol_alt)
+ builder.split(self.package.waypoints.split)
builder.rtb(flight.from_cp)
flight.points = builder.build()
@@ -329,6 +337,7 @@ class FlightPlanBuilder:
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):
@@ -346,11 +355,12 @@ class FlightPlanBuilder:
builder = WaypointBuilder(self.doctrine)
builder.ascent(flight.from_cp, is_helo)
- builder.join(self.package.join_point)
+ builder.hold(self._hold_point(flight))
+ builder.join(self.package.waypoints.join)
builder.ingress_cas(ingress, location)
builder.cas(center, cap_alt)
builder.egress(egress, location)
- builder.split(self.package.split_point)
+ builder.split(self.package.waypoints.split)
builder.rtb(flight.from_cp, is_helo)
flight.points = builder.build()
@@ -386,16 +396,12 @@ class FlightPlanBuilder:
builder.land(arrival)
return builder.build()[0]
- def _join_point(self) -> Point:
- ingress_point = self.package.ingress_point
- assert ingress_point is not None
+ def _join_point(self, ingress_point: Point) -> Point:
heading = self._heading_to_package_airfield(ingress_point)
return ingress_point.point_from_heading(heading,
-self.doctrine.join_distance)
- def _split_point(self) -> Point:
- egress_point = self.package.egress_point
- assert egress_point is not None
+ def _split_point(self, egress_point: Point) -> Point:
heading = self._heading_to_package_airfield(egress_point)
return egress_point.point_from_heading(heading,
-self.doctrine.split_distance)
diff --git a/gen/flights/waypointbuilder.py b/gen/flights/waypointbuilder.py
index 6fc3fd85..a0374d25 100644
--- a/gen/flights/waypointbuilder.py
+++ b/gen/flights/waypointbuilder.py
@@ -25,6 +25,7 @@ class WaypointBuilder:
Args:
departure: Departure airfield or carrier.
+ is_helo: True if the flight is a helicopter.
"""
# TODO: Pick runway based on wind direction.
heading = departure.heading
@@ -48,6 +49,7 @@ class WaypointBuilder:
Args:
arrival: Arrival airfield or carrier.
+ is_helo: True if the flight is a helicopter.
"""
# TODO: Pick runway based on wind direction.
# ControlPoint.heading is the departure heading.
@@ -64,7 +66,7 @@ class WaypointBuilder:
waypoint.name = "DESCEND"
waypoint.alt_type = "RADIO"
waypoint.description = "Descend to pattern altitude"
- waypoint.pretty_name = "Ascend"
+ waypoint.pretty_name = "Descend"
self.waypoints.append(waypoint)
def land(self, arrival: ControlPoint) -> None:
@@ -86,6 +88,18 @@ class WaypointBuilder:
waypoint.pretty_name = "Land"
self.waypoints.append(waypoint)
+ def hold(self, position: Point) -> None:
+ waypoint = FlightWaypoint(
+ FlightWaypointType.LOITER,
+ position.x,
+ position.y,
+ self.doctrine.rendezvous_altitude
+ )
+ waypoint.pretty_name = "Hold"
+ waypoint.description = "Wait until push time"
+ waypoint.name = "HOLD"
+ self.waypoints.append(waypoint)
+
def join(self, position: Point) -> None:
waypoint = FlightWaypoint(
FlightWaypointType.JOIN,
@@ -170,7 +184,7 @@ class WaypointBuilder:
location)
def _target_point(self, target: Union[TheaterGroundObject, Unit], name: str,
- description: str, location: MissionTarget) -> None:
+ description: str, location: MissionTarget) -> None:
if self.ingress_point is None:
raise RuntimeError(
"An ingress point must be added before target points."
@@ -293,6 +307,7 @@ class WaypointBuilder:
Args:
arrival: Arrival airfield or carrier.
+ is_helo: True if the flight is a helicopter.
"""
self.descent(arrival, is_helo)
self.land(arrival)
diff --git a/gen/kneeboard.py b/gen/kneeboard.py
index e7a86bc5..cea2e591 100644
--- a/gen/kneeboard.py
+++ b/gen/kneeboard.py
@@ -24,6 +24,7 @@ aircraft will be able to see the enemy's kneeboard for the same airframe.
"""
from collections import defaultdict
from dataclasses import dataclass
+import datetime
from pathlib import Path
from typing import Dict, List, Optional, Tuple
@@ -106,7 +107,8 @@ class NumberedWaypoint:
class FlightPlanBuilder:
- def __init__(self) -> None:
+ def __init__(self, start_time: datetime.datetime) -> None:
+ self.start_time = start_time
self.rows: List[List[str]] = []
self.target_points: List[NumberedWaypoint] = []
@@ -133,16 +135,24 @@ class FlightPlanBuilder:
self.rows.append([
f"{first_waypoint_num}-{last_waypoint_num}",
"Target points",
- "0"
+ "0",
+ self._format_time(self.target_points[0].waypoint.tot),
])
def add_waypoint_row(self, waypoint: NumberedWaypoint) -> None:
self.rows.append([
str(waypoint.number),
waypoint.waypoint.pretty_name,
- str(int(units.meters_to_feet(waypoint.waypoint.alt)))
+ str(int(units.meters_to_feet(waypoint.waypoint.alt))),
+ self._format_time(waypoint.waypoint.tot),
])
+ def _format_time(self, time: Optional[int]) -> str:
+ if time is None:
+ return ""
+ local_time = self.start_time + datetime.timedelta(seconds=time)
+ return local_time.strftime(f"%H:%M:%S LOCAL")
+
def build(self) -> List[List[str]]:
return self.rows
@@ -151,12 +161,13 @@ class BriefingPage(KneeboardPage):
"""A kneeboard page containing briefing information."""
def __init__(self, flight: FlightData, comms: List[CommInfo],
awacs: List[AwacsInfo], tankers: List[TankerInfo],
- jtacs: List[JtacInfo]) -> None:
+ jtacs: List[JtacInfo], start_time: datetime.datetime) -> None:
self.flight = flight
self.comms = list(comms)
self.awacs = awacs
self.tankers = tankers
self.jtacs = jtacs
+ self.start_time = start_time
self.comms.append(CommInfo("Flight", self.flight.intra_flight_channel))
def write(self, path: Path) -> None:
@@ -172,11 +183,11 @@ class BriefingPage(KneeboardPage):
], headers=["", "Airbase", "ATC", "TCN", "I(C)LS", "RWY"])
writer.heading("Flight Plan")
- flight_plan_builder = FlightPlanBuilder()
+ flight_plan_builder = FlightPlanBuilder(self.start_time)
for num, waypoint in enumerate(self.flight.waypoints):
flight_plan_builder.add_waypoint(num, waypoint)
writer.table(flight_plan_builder.build(),
- headers=["STPT", "Action", "Alt"])
+ headers=["STPT", "Action", "Alt", "TOT"])
writer.heading("Comm Ladder")
comms = []
@@ -297,6 +308,11 @@ class KneeboardGenerator(MissionInfoGenerator):
"""Returns a list of kneeboard pages for the given flight."""
return [
BriefingPage(
- flight, self.comms, self.awacs, self.tankers, self.jtacs
+ flight,
+ self.comms,
+ self.awacs,
+ self.tankers,
+ self.jtacs,
+ self.mission.start_time
),
]
diff --git a/qt_ui/models.py b/qt_ui/models.py
index 87d52538..98515eab 100644
--- a/qt_ui/models.py
+++ b/qt_ui/models.py
@@ -1,4 +1,6 @@
"""Qt data models for game objects."""
+import datetime
+from enum import auto, IntEnum
from typing import Any, Callable, Dict, Iterator, TypeVar, Optional
from PySide2.QtCore import (
@@ -156,6 +158,9 @@ class PackageModel(QAbstractListModel):
"""Returns the flight located at the given index."""
return self.package.flights[index.row()]
+ def update_tot(self, tot: int) -> None:
+ self.package.time_over_target = tot
+
@property
def mission_target(self) -> MissionTarget:
"""Returns the mission target of the package."""
@@ -178,6 +183,8 @@ class PackageModel(QAbstractListModel):
class AtoModel(QAbstractListModel):
"""The model for an AirTaskingOrder."""
+ PackageRole = Qt.UserRole
+
def __init__(self, game: Optional[Game], ato: AirTaskingOrder) -> None:
super().__init__()
self.game = game
@@ -190,9 +197,11 @@ class AtoModel(QAbstractListModel):
def data(self, index: QModelIndex, role: int = Qt.DisplayRole) -> Any:
if not index.isValid():
return None
+ package = self.ato.packages[index.row()]
if role == Qt.DisplayRole:
- package = self.ato.packages[index.row()]
return f"{package.package_description} {package.target.name}"
+ elif role == AtoModel.PackageRole:
+ return package
return None
def add_package(self, package: Package) -> None:
diff --git a/qt_ui/widgets/QTopPanel.py b/qt_ui/widgets/QTopPanel.py
index fae3a11d..f75826ad 100644
--- a/qt_ui/widgets/QTopPanel.py
+++ b/qt_ui/widgets/QTopPanel.py
@@ -1,6 +1,12 @@
from typing import Optional
-from PySide2.QtWidgets import QFrame, QGroupBox, QHBoxLayout, QPushButton
+from PySide2.QtWidgets import (
+ QFrame,
+ QGroupBox,
+ QHBoxLayout,
+ QMessageBox,
+ QPushButton,
+)
import qt_ui.uiconstants as CONST
from game import Game
@@ -9,9 +15,10 @@ from qt_ui.widgets.QBudgetBox import QBudgetBox
from qt_ui.widgets.QFactionsInfos import QFactionsInfos
from qt_ui.widgets.QTurnCounter import QTurnCounter
from qt_ui.windows.GameUpdateSignal import GameUpdateSignal
+from qt_ui.windows.QWaitingForMissionResultWindow import \
+ QWaitingForMissionResultWindow
from qt_ui.windows.settings.QSettingsWindow import QSettingsWindow
from qt_ui.windows.stats.QStatsWindow import QStatsWindow
-from qt_ui.windows.QWaitingForMissionResultWindow import QWaitingForMissionResultWindow
class QTopPanel(QFrame):
@@ -101,8 +108,36 @@ class QTopPanel(QFrame):
GameUpdateSignal.get_instance().updateGame(self.game)
self.proceedButton.setEnabled(True)
+ def ato_has_clients(self) -> bool:
+ for package in self.game.blue_ato.packages:
+ for flight in package.flights:
+ if flight.client_count > 0:
+ return True
+ return False
+
+ def confirm_no_client_launch(self) -> bool:
+ result = QMessageBox.question(
+ self,
+ "Continue without client slots?",
+ ("No client slots have been created for players. Continuing will "
+ "allow the AI to perform the mission, but players will be unable "
+ "to participate.
"
+ "
"
+ "To add client slots for players, select a package from the "
+ "Packages panel on the left of the main window, and then a flight "
+ "from the Flights panel below the Packages panel. The edit button "
+ "below the Flights panel will allow you to edit the number of "
+ "client slots in the flight. Each client slot allows one player."),
+ QMessageBox.No,
+ QMessageBox.Yes
+ )
+ return result == QMessageBox.Yes
+
def launch_mission(self):
"""Finishes planning and waits for mission completion."""
+ if not self.ato_has_clients() and not self.confirm_no_client_launch():
+ return
+
# TODO: Refactor this nonsense.
game_event = None
for event in self.game.events:
diff --git a/qt_ui/widgets/ato.py b/qt_ui/widgets/ato.py
index e4c178c4..b39f3063 100644
--- a/qt_ui/widgets/ato.py
+++ b/qt_ui/widgets/ato.py
@@ -1,8 +1,16 @@
"""Widgets for displaying air tasking orders."""
+import datetime
import logging
-from typing import Optional
+from contextlib import contextmanager
+from typing import ContextManager, Optional
-from PySide2.QtCore import QItemSelectionModel, QModelIndex, QSize, Qt
+from PySide2.QtCore import (
+ QItemSelectionModel,
+ QModelIndex,
+ QSize,
+ Qt,
+)
+from PySide2.QtGui import QFont, QFontMetrics, QPainter
from PySide2.QtWidgets import (
QAbstractItemView,
QGroupBox,
@@ -10,13 +18,13 @@ from PySide2.QtWidgets import (
QListView,
QPushButton,
QSplitter,
- QVBoxLayout,
+ QStyleOptionViewItem, QStyledItemDelegate, QVBoxLayout,
)
from gen.ato import Package
from gen.flights.flight import Flight
-from ..models import AtoModel, GameModel, NullListModel, PackageModel
from qt_ui.windows.GameUpdateSignal import GameUpdateSignal
+from ..models import AtoModel, GameModel, NullListModel, PackageModel
class QFlightList(QListView):
@@ -138,6 +146,65 @@ class QFlightPanel(QGroupBox):
GameUpdateSignal.get_instance().redraw_flight_paths()
+@contextmanager
+def painter_context(painter: QPainter) -> ContextManager[None]:
+ try:
+ painter.save()
+ yield
+ finally:
+ painter.restore()
+
+
+class PackageDelegate(QStyledItemDelegate):
+ FONT_SIZE = 12
+ HMARGIN = 4
+ VMARGIN = 4
+
+ def get_font(self, option: QStyleOptionViewItem) -> QFont:
+ font = QFont(option.font)
+ font.setPointSize(self.FONT_SIZE)
+ return font
+
+ @staticmethod
+ def package(index: QModelIndex) -> Package:
+ return index.data(AtoModel.PackageRole)
+
+ def left_text(self, index: QModelIndex) -> str:
+ package = self.package(index)
+ return f"{package.package_description} {package.target.name}"
+
+ 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}"
+
+ def paint(self, painter: QPainter, option: QStyleOptionViewItem,
+ index: QModelIndex) -> None:
+ # Draw the list item with all the default selection styling, but with an
+ # invalid index so text formatting is left to us.
+ super().paint(painter, option, QModelIndex())
+
+ rect = option.rect.adjusted(self.HMARGIN, self.VMARGIN, -self.HMARGIN,
+ -self.VMARGIN)
+
+ with painter_context(painter):
+ painter.setFont(self.get_font(option))
+
+ painter.drawText(rect, Qt.AlignLeft, self.left_text(index))
+ line2 = rect.adjusted(0, rect.height() / 2, 0, rect.height() / 2)
+ painter.drawText(line2, Qt.AlignLeft, self.right_text(index))
+
+ def sizeHint(self, option: QStyleOptionViewItem,
+ index: QModelIndex) -> QSize:
+ metrics = QFontMetrics(self.get_font(option))
+ left = metrics.size(0, self.left_text(index))
+ right = metrics.size(0, self.right_text(index))
+ return QSize(max(left.width(), right.width()) + 2 * self.HMARGIN,
+ left.height() + right.height() + 2 * self.VMARGIN)
+
+
class QPackageList(QListView):
"""List view for displaying the packages of an ATO."""
@@ -145,6 +212,7 @@ class QPackageList(QListView):
super().__init__()
self.ato_model = model
self.setModel(model)
+ self.setItemDelegate(PackageDelegate())
self.setIconSize(QSize(91, 24))
self.setSelectionBehavior(QAbstractItemView.SelectItems)
diff --git a/qt_ui/windows/mission/QPackageDialog.py b/qt_ui/windows/mission/QPackageDialog.py
index 21a44aa3..d9deb613 100644
--- a/qt_ui/windows/mission/QPackageDialog.py
+++ b/qt_ui/windows/mission/QPackageDialog.py
@@ -2,12 +2,13 @@
import logging
from typing import Optional
-from PySide2.QtCore import QItemSelection, Signal
+from PySide2.QtCore import QItemSelection, QTime, Signal
from PySide2.QtWidgets import (
QDialog,
QHBoxLayout,
QLabel,
QPushButton,
+ QTimeEdit,
QVBoxLayout,
)
@@ -56,14 +57,39 @@ class QPackageDialog(QDialog):
self.summary_row = QHBoxLayout()
self.layout.addLayout(self.summary_row)
+ self.package_type_column = QHBoxLayout()
+ self.summary_row.addLayout(self.package_type_column)
+
self.package_type_label = QLabel("Package Type:")
self.package_type_text = QLabel(self.package_model.description)
# noinspection PyUnresolvedReferences
self.package_changed.connect(lambda: self.package_type_text.setText(
self.package_model.description
))
- self.summary_row.addWidget(self.package_type_label)
- self.summary_row.addWidget(self.package_type_text)
+ self.package_type_column.addWidget(self.package_type_label)
+ self.package_type_column.addWidget(self.package_type_text)
+
+ self.summary_row.addStretch(1)
+
+ self.tot_column = QHBoxLayout()
+ self.summary_row.addLayout(self.tot_column)
+
+ self.tot_label = QLabel("Time Over Target:")
+ self.tot_column.addWidget(self.tot_label)
+
+ if self.package_model.package.time_over_target is None:
+ time = None
+ else:
+ delay = self.package_model.package.time_over_target
+ hours = delay // 3600
+ minutes = delay // 60 % 60
+ seconds = delay % 60
+ time = QTime(hours, minutes, seconds)
+
+ self.tot_spinner = QTimeEdit(time)
+ self.tot_spinner.setMinimumTime(QTime(0, 0))
+ self.tot_spinner.setDisplayFormat("T+hh:mm:ss")
+ self.tot_column.addWidget(self.tot_spinner)
self.package_view = QFlightList(self.package_model)
self.package_view.selectionModel().selectionChanged.connect(
@@ -90,8 +116,10 @@ class QPackageDialog(QDialog):
self.finished.connect(self.on_close)
- @staticmethod
- def on_close(_result) -> None:
+ def on_close(self, _result) -> None:
+ time = self.tot_spinner.time()
+ seconds = time.hour() * 3600 + time.minute() * 60 + time.second()
+ self.package_model.update_tot(seconds)
GameUpdateSignal.get_instance().redraw_flight_paths()
def on_selection_changed(self, selected: QItemSelection,
diff --git a/qt_ui/windows/mission/flight/waypoints/QFlightWaypointList.py b/qt_ui/windows/mission/flight/waypoints/QFlightWaypointList.py
index 8deefbc7..aa904e2d 100644
--- a/qt_ui/windows/mission/flight/waypoints/QFlightWaypointList.py
+++ b/qt_ui/windows/mission/flight/waypoints/QFlightWaypointList.py
@@ -1,26 +1,33 @@
+import datetime
+import itertools
+
from PySide2.QtCore import QItemSelectionModel, QPoint
-from PySide2.QtGui import QStandardItemModel, QStandardItem
-from PySide2.QtWidgets import QTableView, QHeaderView
+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 QWaypointItem
+from qt_ui.windows.mission.flight.waypoints.QFlightWaypointItem import \
+ QWaypointItem
class QFlightWaypointList(QTableView):
- def __init__(self, flight: Flight):
- super(QFlightWaypointList, self).__init__()
+ def __init__(self, package: Package, flight: Flight):
+ super().__init__()
+ self.package = package
+ self.flight = flight
+
self.model = QStandardItemModel(self)
self.setModel(self.model)
self.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
- self.model.setHorizontalHeaderLabels(["Name", "Alt"])
+ self.model.setHorizontalHeaderLabels(["Name", "Alt", "TOT/DEPART"])
header = self.horizontalHeader()
header.setSectionResizeMode(0, QHeaderView.ResizeToContents)
- self.flight = flight
-
if len(self.flight.points) > 0:
self.selectedPoint = self.flight.points[0]
self.update_list()
@@ -33,18 +40,49 @@ class QFlightWaypointList(QTableView):
def update_list(self):
self.model.clear()
- self.model.setHorizontalHeaderLabels(["Name", "Alt"])
- takeoff = FlightWaypoint(self.flight.from_cp.position.x, self.flight.from_cp.position.y, 0)
+
+ 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,
+ self.flight.from_cp.position.y, 0)
takeoff.description = "Take Off"
takeoff.name = takeoff.pretty_name = "Take Off from " + self.flight.from_cp.name
- self.model.appendRow(QWaypointItem(takeoff, 0))
- item = QStandardItem("0 feet AGL")
- item.setEditable(False)
- self.model.setItem(0, 1, item)
- for i, point in enumerate(self.flight.points):
- self.model.insertRow(self.model.rowCount())
- self.model.setItem(self.model.rowCount()-1, 0, QWaypointItem(point, i + 1))
- item = QStandardItem(str(meter_to_feet(point.alt)) + " ft " + str(["AGL" if point.alt_type == "RADIO" else "MSL"][0]))
- item.setEditable(False)
- self.model.setItem(self.model.rowCount()-1, 1, item)
- self.selectionModel().setCurrentIndex(self.indexAt(QPoint(1, 1)), QItemSelectionModel.Select)
\ No newline at end of file
+ takeoff.alt_type = "RADIO"
+
+ waypoints = itertools.chain([takeoff], self.flight.points)
+ for row, waypoint in enumerate(waypoints):
+ self.add_waypoint_row(row, waypoint, timing)
+ self.selectionModel().setCurrentIndex(self.indexAt(QPoint(1, 1)),
+ QItemSelectionModel.Select)
+
+ def add_waypoint_row(self, row: int, waypoint: FlightWaypoint,
+ timing: PackageWaypointTiming) -> None:
+ self.model.insertRow(self.model.rowCount())
+
+ self.model.setItem(row, 0, QWaypointItem(waypoint, row))
+
+ altitude = meter_to_feet(waypoint.alt)
+ altitude_type = "AGL" if waypoint.alt_type == "RADIO" else "MSL"
+ altitude_item = QStandardItem(f"{altitude} ft {altitude_type}")
+ altitude_item.setEditable(False)
+ self.model.setItem(row, 1, altitude_item)
+
+ tot = self.tot_text(waypoint, timing)
+ tot_item = QStandardItem(tot)
+ tot_item.setEditable(False)
+ self.model.setItem(row, 2, tot_item)
+
+ def tot_text(self, waypoint: FlightWaypoint,
+ timing: PackageWaypointTiming) -> str:
+ prefix = ""
+ time = timing.tot_for_waypoint(waypoint)
+ if time is None:
+ prefix = "Depart "
+ time = timing.depart_time_for_waypoint(waypoint, self.flight)
+ if time is None:
+ return ""
+ return f"{prefix}T+{datetime.timedelta(seconds=time)}"
diff --git a/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py b/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py
index 5e9e57a2..98064b5c 100644
--- a/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py
+++ b/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py
@@ -44,7 +44,8 @@ class QFlightWaypointTab(QFrame):
def init_ui(self):
layout = QGridLayout()
- self.flight_waypoint_list = QFlightWaypointList(self.flight)
+ self.flight_waypoint_list = QFlightWaypointList(self.package,
+ self.flight)
layout.addWidget(self.flight_waypoint_list, 0, 0)
rlayout = QVBoxLayout()