From 3f16c0378af6e08ba262f6fa9870def2233aec6c Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Mon, 19 Apr 2021 23:09:39 -0700 Subject: [PATCH] Add BAI planning against supply routes. This currently is only supported for player flights. I have no idea how to create an AI flight plan that won't just get them killed. AI-only BAI missions against supply routes will warn the player on mission creation. --- game/theater/supplyroutes.py | 24 ++++++++ gen/flights/flightplan.py | 79 ++++++++++++++++++++++++- gen/flights/waypointbuilder.py | 57 ++++++++++++++++++ qt_ui/widgets/QTopPanel.py | 38 ++++++++++++ qt_ui/widgets/map/SupplyRouteSegment.py | 42 ++++++++++++- 5 files changed, 238 insertions(+), 2 deletions(-) diff --git a/game/theater/supplyroutes.py b/game/theater/supplyroutes.py index eb02663d..790f95a8 100644 --- a/game/theater/supplyroutes.py +++ b/game/theater/supplyroutes.py @@ -6,6 +6,8 @@ from collections import defaultdict from dataclasses import dataclass, field from typing import Dict, Iterator, List, Optional +from dcs import Point +from game.theater import FlightType, MissionTarget from game.theater.controlpoint import ControlPoint @@ -97,3 +99,25 @@ class SupplyRoute: current = previous path.reverse() return path + + +class SupplyRouteLink(MissionTarget): + def __init__(self, a: ControlPoint, b: ControlPoint) -> None: + self.control_point_a = a + self.control_point_b = b + super().__init__( + f"Supply route between {a} and {b}", + Point((a.position.x + b.position.x) / 2, (a.position.y + b.position.y) / 2), + ) + + def mission_types(self, for_player: bool) -> Iterator[FlightType]: + yield from [ + FlightType.BAI, + # TODO: Escort + # TODO: SEAD + # TODO: Recon + # TODO: TARCAP + ] + + def is_friendly(self, to_player: bool) -> bool: + return self.control_point_a.captured diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py index 2b929387..85c2895a 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -30,6 +30,7 @@ from game.theater import ( SamGroundObject, TheaterGroundObject, ) +from game.theater.supplyroutes import SupplyRouteLink from game.theater.theatergroundobject import EwrGroundObject from game.utils import Distance, Speed, feet, meters, nautical_miles from .closestairfields import ObjectiveDistanceCache @@ -466,6 +467,25 @@ class CasFlightPlan(PatrollingFlightPlan): return self.patrol_end +@dataclass(frozen=True) +class ConvoyInterdictionFlightPlan(PatrollingFlightPlan): + takeoff: FlightWaypoint + land: FlightWaypoint + divert: Optional[FlightWaypoint] + + def iter_waypoints(self) -> Iterator[FlightWaypoint]: + yield self.takeoff + yield from self.nav_to + yield from [ + self.patrol_start, + self.patrol_end, + ] + yield from self.nav_from + yield self.land + if self.divert is not None: + yield self.divert + + @dataclass(frozen=True) class TarCapFlightPlan(PatrollingFlightPlan): takeoff: FlightWaypoint @@ -1014,7 +1034,7 @@ class FlightPlanBuilder: hold_duration=timedelta(hours=4), ) - def generate_bai(self, flight: Flight) -> StrikeFlightPlan: + def generate_bai(self, flight: Flight) -> FlightPlan: """Generates a BAI flight plan. Args: @@ -1022,6 +1042,9 @@ class FlightPlanBuilder: """ location = self.package.target + if isinstance(location, SupplyRouteLink): + return self.generate_supply_route_bai(flight, location) + if not isinstance(location, TheaterGroundObject): raise InvalidObjectiveLocation(flight.flight_type, location) @@ -1034,6 +1057,60 @@ class FlightPlanBuilder: flight, location, FlightWaypointType.INGRESS_BAI, targets ) + def generate_supply_route_bai( + self, flight: Flight, location: SupplyRouteLink + ) -> ConvoyInterdictionFlightPlan: + """Generates a BAI flight plan for attacking a supply route. + + These flight plans are extremely rough because we do not know where the roads + are. For now they're mostly only usable by players. The flight plan includes a + start and end patrol point matching the end points of the convoy's route and a + 30 minute time on station. It is up to the player to find the target. + + Args: + flight: The flight to generate the flight plan for. + location: The supply route link to attack. + """ + + origin = self.package_airfield() + a_dist = origin.distance_to(location.control_point_a) + b_dist = origin.distance_to(location.control_point_b) + if a_dist < b_dist: + near = location.control_point_a + far = location.control_point_b + else: + near = location.control_point_b + far = location.control_point_a + + patrol_alt = meters( + random.randint( + int(self.doctrine.min_patrol_altitude.meters), + int(self.doctrine.max_patrol_altitude.meters), + ) + ) + + builder = WaypointBuilder(flight, self.game, self.is_player) + start, end = builder.convoy_search(near, far, patrol_alt) + + return ConvoyInterdictionFlightPlan( + self.package, + flight, + takeoff=builder.takeoff(flight.departure), + nav_to=builder.nav_path( + flight.departure.position, near.position, patrol_alt + ), + nav_from=builder.nav_path( + far.position, flight.arrival.position, patrol_alt + ), + patrol_start=start, + patrol_end=end, + land=builder.land(flight.arrival), + divert=builder.divert(flight.divert), + # Not relevant because player only. + engagement_distance=meters(0), + patrol_duration=timedelta(minutes=30), + ) + def generate_anti_ship(self, flight: Flight) -> StrikeFlightPlan: """Generates an anti-ship flight plan. diff --git a/gen/flights/waypointbuilder.py b/gen/flights/waypointbuilder.py index 53fad47c..ad1db18a 100644 --- a/gen/flights/waypointbuilder.py +++ b/gen/flights/waypointbuilder.py @@ -349,6 +349,63 @@ class WaypointBuilder: self.race_track_end(end, altitude), ) + @staticmethod + def convoy_search_start( + control_point: ControlPoint, altitude: Distance + ) -> FlightWaypoint: + """Creates a convoy search start waypoint. + + Args: + control_point: Control point for the beginning of the search. + altitude: Altitude of the racetrack. + """ + waypoint = FlightWaypoint( + FlightWaypointType.INGRESS_BAI, + control_point.position.x, + control_point.position.y, + altitude, + ) + waypoint.name = control_point.name + waypoint.description = "Beginning of convoy search area" + waypoint.pretty_name = "Search start" + return waypoint + + @staticmethod + def convoy_search_end( + control_point: ControlPoint, altitude: Distance + ) -> FlightWaypoint: + """Creates a convoy search start waypoint. + + Args: + control_point: Control point for the beginning of the search. + altitude: Altitude of the racetrack. + """ + waypoint = FlightWaypoint( + FlightWaypointType.EGRESS, + control_point.position.x, + control_point.position.y, + altitude, + ) + waypoint.name = control_point.name + waypoint.description = "End of convoy search area" + waypoint.pretty_name = "Search end" + return waypoint + + def convoy_search( + self, start: ControlPoint, end: ControlPoint, altitude: Distance + ) -> Tuple[FlightWaypoint, FlightWaypoint]: + """Creates two waypoint for a convoy search path. + + Args: + start: The beginning convoy search waypoint. + end: The ending convoy search waypoint. + altitude: The convoy search altitude. + """ + return ( + self.convoy_search_start(start, altitude), + self.convoy_search_end(end, altitude), + ) + @staticmethod def orbit(start: Point, altitude: Distance) -> FlightWaypoint: """Creates an circular orbit point. diff --git a/qt_ui/widgets/QTopPanel.py b/qt_ui/widgets/QTopPanel.py index f43657f7..f08e84e0 100644 --- a/qt_ui/widgets/QTopPanel.py +++ b/qt_ui/widgets/QTopPanel.py @@ -16,6 +16,8 @@ import qt_ui.uiconstants as CONST from game import Game from game.event.airwar import AirWarEvent from gen.ato import Package +from gen.flights.flight import FlightType +from gen.flights.flightplan import ConvoyInterdictionFlightPlan from gen.flights.traveltime import TotEstimator from qt_ui.models import GameModel from qt_ui.widgets.QBudgetBox import QBudgetBox @@ -199,6 +201,36 @@ class QTopPanel(QFrame): ) return result == QMessageBox.Yes + def ato_has_ai_convoy_interdiction(self) -> bool: + for package in self.game.blue_ato.packages: + for flight in package.flights: + if ( + isinstance(flight.flight_plan, ConvoyInterdictionFlightPlan) + and not flight.client_count + ): + return True + return False + + def confirm_ai_convoy_interdiction_launch(self) -> bool: + result = QMessageBox.question( + self, + "Continue with AI convoy interdiction missions?", + ( + "AI only convoy interdiction missions were planned. AI behavior for " + "these missions has not been developed so they will probably get " + "themselves killed. Continuing is not recommended.
" + "
" + "To remove AI convoy interdiction missions, delete any BAI flights " + "that are planned against supply route objectives.
" + "
" + "Click 'Yes' to continue with AI only convoy interdiction missions." + "

Click 'No' to cancel and revise your flight planning." + ), + QMessageBox.No, + QMessageBox.Yes, + ) + return result == QMessageBox.Yes + def confirm_negative_start_time(self, negative_starts: List[Package]) -> bool: formatted = "
".join( [f"{p.primary_task} {p.target.name}" for p in negative_starts] @@ -241,6 +273,12 @@ class QTopPanel(QFrame): if not self.ato_has_clients() and not self.confirm_no_client_launch(): return + if ( + self.ato_has_ai_convoy_interdiction() + and not self.confirm_ai_convoy_interdiction_launch() + ): + return + negative_starts = self.negative_start_packages() if negative_starts: if not self.confirm_negative_start_time(negative_starts): diff --git a/qt_ui/widgets/map/SupplyRouteSegment.py b/qt_ui/widgets/map/SupplyRouteSegment.py index 02f8ce33..58fda738 100644 --- a/qt_ui/widgets/map/SupplyRouteSegment.py +++ b/qt_ui/widgets/map/SupplyRouteSegment.py @@ -3,10 +3,19 @@ from typing import List, Optional from PySide2.QtCore import Qt from PySide2.QtGui import QColor, QPen -from PySide2.QtWidgets import QGraphicsItem, QGraphicsLineItem +from PySide2.QtWidgets import ( + QAction, + QGraphicsItem, + QGraphicsLineItem, + QGraphicsSceneContextMenuEvent, + QGraphicsSceneHoverEvent, + QMenu, +) from game.theater import ControlPoint +from game.theater.supplyroutes import SupplyRouteLink from game.transfers import RoadTransferOrder +from qt_ui.dialogs import Dialog from qt_ui.uiconstants import COLORS @@ -28,6 +37,7 @@ class SupplyRouteSegment(QGraphicsLineItem): self.convoys = convoys self.setPen(self.make_pen()) self.setToolTip(self.make_tooltip()) + self.setAcceptHoverEvents(True) @cached_property def convoy_size(self) -> int: @@ -71,3 +81,33 @@ class SupplyRouteSegment(QGraphicsLineItem): @property def has_convoys(self) -> bool: return bool(self.convoys) + + @property + def targetable(self) -> bool: + return self.convoys and not self.control_point_a.captured + + def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None: + # Can only plan missions against enemy supply routes that have convoys. + if not self.targetable: + super().contextMenuEvent(event) + return + + menu = QMenu("Menu") + + new_package_action = QAction(f"New package") + new_package_action.triggered.connect(self.open_new_package_dialog) + menu.addAction(new_package_action) + + menu.exec_(event.screenPos()) + + def open_new_package_dialog(self) -> None: + """Opens the dialog for planning a new mission package.""" + Dialog.open_new_package_dialog( + SupplyRouteLink(self.control_point_a, self.control_point_b) + ) + + def hoverEnterEvent(self, event: QGraphicsSceneHoverEvent): + if self.targetable: + self.setCursor(Qt.PointingHandCursor) + else: + super().hoverEnterEvent(event)