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)