From 5ce942c9a05ae0c692c01326e5dae58174861e2a Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Wed, 7 Oct 2020 17:09:57 -0700 Subject: [PATCH 1/4] Confirm mission start when no client slots exist. Especially considering the button in this position used to be how players added client slots, confirm that they in fact want to launch an AI-only mission before launching, and guide them toward the new UI. --- qt_ui/widgets/QTopPanel.py | 39 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) 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: From f0279a6866e4b7aee64da734e0e6975c3834d1b2 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Thu, 8 Oct 2020 17:58:04 -0700 Subject: [PATCH 2/4] Ignore the entire logs directory. We use a rotating log handler, so we generate more than just the one file. --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 7abe32be5ce50234569bcfa4d072346f6ef5f2af Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Thu, 8 Oct 2020 23:47:11 -0700 Subject: [PATCH 3/4] Fix name of decent waypoint. --- gen/flights/waypointbuilder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gen/flights/waypointbuilder.py b/gen/flights/waypointbuilder.py index 6fc3fd85..592ec720 100644 --- a/gen/flights/waypointbuilder.py +++ b/gen/flights/waypointbuilder.py @@ -64,7 +64,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: From b5e5a3b2daa43960d943bcc0c5216534265de1e9 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Mon, 5 Oct 2020 23:07:37 -0700 Subject: [PATCH 4/4] Plan waypoint TOTs. Also fixes the CAP racetracks so the AI actually stays on station. Waypoint TOT assignment happens at mission generation time for the sake of the UI. It's a bit messy since we have the late-initialized field in FlightWaypoint, but on the other hand we don't have to reset every extant waypoint whenever the player adjusts the mission's TOT. If we want to clean this up a bit more, we could have two distinct types for waypoints: one for the planning stage and one with the resolved TOTs. We already do some thing like this with Flight vs FlightData. Future improvements: * Estimate the group's ground speed so we don't need such wide margins of error. * Delay takeoff to cut loiter fuel cost. * Plan mission TOT based on the aircraft in the package and their travel times to the objective. * Tune target area time prediction. Flights often don't need to travel all the way to the target point, and probably won't be doing it slowly, so the current planning causes a lot of extra time spent in enemy territory. * Per-flight TOT offsets from the package to allow a sweep to arrive before the rest, etc. --- gen/aircraft.py | 596 ++++++++++++++---- gen/ato.py | 26 +- gen/flights/ai_flight_planner.py | 17 +- gen/flights/flight.py | 16 + gen/flights/flightplan.py | 98 +-- gen/flights/waypointbuilder.py | 17 +- gen/kneeboard.py | 30 +- qt_ui/models.py | 11 +- qt_ui/widgets/ato.py | 76 ++- qt_ui/windows/mission/QPackageDialog.py | 38 +- .../flight/waypoints/QFlightWaypointList.py | 80 ++- .../flight/waypoints/QFlightWaypointTab.py | 3 +- 12 files changed, 782 insertions(+), 226 deletions(-) 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 592ec720..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. @@ -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/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()