diff --git a/game/data/doctrine.py b/game/data/doctrine.py index 4f944833..21402501 100644 --- a/game/data/doctrine.py +++ b/game/data/doctrine.py @@ -47,8 +47,13 @@ class Doctrine: #: fallback flight plan layout (when the departure airfield is near a threat zone). join_distance: Distance - #: The distance between the ingress point (beginning of the attack) and target. - ingress_distance: Distance + #: The maximum distance between the ingress point (beginning of the attack) and + #: target. + max_ingress_distance: Distance + + #: The minimum distance between the ingress point (beginning of the attack) and + #: target. + min_ingress_distance: Distance ingress_altitude: Distance @@ -87,9 +92,22 @@ class Doctrine: @has_save_compat_for(5) def __setstate__(self, state: dict[str, Any]) -> None: - if "ingress_distance" not in state: - state["ingress_distance"] = state["ingress_egress_distance"] - del state["ingress_egress_distance"] + if "max_ingress_distance" not in state: + try: + state["max_ingress_distance"] = state["ingress_distance"] + del state["ingress_distance"] + except KeyError: + state["max_ingress_distance"] = state["ingress_egress_distance"] + del state["ingress_egress_distance"] + + max_ip: Distance = state["max_ingress_distance"] + if "min_ingress_distance" not in state: + if max_ip < nautical_miles(10): + min_ip = nautical_miles(5) + else: + min_ip = nautical_miles(10) + state["min_ingress_distance"] = min_ip + self.__dict__.update(state) @@ -103,7 +121,8 @@ MODERN_DOCTRINE = Doctrine( hold_distance=nautical_miles(15), push_distance=nautical_miles(20), join_distance=nautical_miles(20), - ingress_distance=nautical_miles(45), + max_ingress_distance=nautical_miles(45), + min_ingress_distance=nautical_miles(10), ingress_altitude=feet(20000), min_patrol_altitude=feet(15000), max_patrol_altitude=feet(33000), @@ -139,7 +158,8 @@ COLDWAR_DOCTRINE = Doctrine( hold_distance=nautical_miles(10), push_distance=nautical_miles(10), join_distance=nautical_miles(10), - ingress_distance=nautical_miles(30), + max_ingress_distance=nautical_miles(30), + min_ingress_distance=nautical_miles(10), ingress_altitude=feet(18000), min_patrol_altitude=feet(10000), max_patrol_altitude=feet(24000), @@ -175,7 +195,8 @@ WWII_DOCTRINE = Doctrine( push_distance=nautical_miles(5), join_distance=nautical_miles(5), rendezvous_altitude=feet(10000), - ingress_distance=nautical_miles(7), + max_ingress_distance=nautical_miles(7), + min_ingress_distance=nautical_miles(5), ingress_altitude=feet(8000), min_patrol_altitude=feet(4000), max_patrol_altitude=feet(15000), diff --git a/game/flightplan/__init__.py b/game/flightplan/__init__.py new file mode 100644 index 00000000..51dce4a3 --- /dev/null +++ b/game/flightplan/__init__.py @@ -0,0 +1 @@ +from .ipzonegeometry import IpZoneGeometry diff --git a/game/flightplan/ipzonegeometry.py b/game/flightplan/ipzonegeometry.py new file mode 100644 index 00000000..92e647c2 --- /dev/null +++ b/game/flightplan/ipzonegeometry.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import shapely.ops +from dcs import Point +from shapely.geometry import Point as ShapelyPoint + +from game.utils import nautical_miles, meters + +if TYPE_CHECKING: + from game.coalition import Coalition + + +class IpZoneGeometry: + """Defines the zones used for finding optimal IP placement. + + The zones themselves are stored in the class rather than just the resulting IP so + that the zones can be drawn in the map for debugging purposes. + """ + + def __init__( + self, + target: Point, + home: Point, + coalition: Coalition, + ) -> None: + self.threat_zone = coalition.opponent.threat_zone.all + self.home = ShapelyPoint(home.x, home.y) + + max_ip_distance = coalition.doctrine.max_ingress_distance + min_ip_distance = coalition.doctrine.min_ingress_distance + + # The minimum distance between the home location and the IP. + min_distance_from_home = nautical_miles(5) + + # The distance that is expected to be needed between the beginning of the attack + # and weapon release. This buffers the threat zone to give a 5nm window between + # the edge of the "safe" zone and the actual threat so that "safe" IPs are less + # likely to end up with the attacker entering a threatened area. + attack_distance_buffer = nautical_miles(5) + + home_threatened = coalition.opponent.threat_zone.threatened(home) + + shapely_target = ShapelyPoint(target.x, target.y) + home_to_target_distance = meters(home.distance_to_point(target)) + + self.home_bubble = self.home.buffer(home_to_target_distance.meters).difference( + self.home.buffer(min_distance_from_home.meters) + ) + + # If the home zone is not threatened and home is within LAR, constrain the max + # range to the home-to-target distance to prevent excessive backtracking. + # + # If the home zone *is* threatened, we need to back out of the zone to + # rendezvous anyway. + if not home_threatened and ( + min_ip_distance < home_to_target_distance < max_ip_distance + ): + max_ip_distance = home_to_target_distance + max_ip_bubble = shapely_target.buffer(max_ip_distance.meters) + min_ip_bubble = shapely_target.buffer(min_ip_distance.meters) + self.ip_bubble = max_ip_bubble.difference(min_ip_bubble) + + # The intersection of the home bubble and IP bubble will be all the points that + # are within the valid IP range that are not farther from home than the target + # is. However, if the origin airfield is threatened but there are safe + # placements for the IP, we should not constrain to the home zone. In this case + # we'll either end up with a safe zone outside the home zone and pick the + # closest point in to to home (minimizing backtracking), or we'll have no safe + # IP anywhere within range of the target, and we'll later pick the IP nearest + # the edge of the threat zone. + if home_threatened: + self.permissible_zone = self.ip_bubble + else: + self.permissible_zone = self.ip_bubble.intersection(self.home_bubble) + + if self.permissible_zone.is_empty: + # If home is closer to the target than the min range, there will not be an + # IP solution that's close enough to home, in which case we need to ignore + # the home bubble. + self.permissible_zone = self.ip_bubble + + self.safe_zone = self.permissible_zone.difference( + self.threat_zone.buffer(attack_distance_buffer.meters) + ) + + def _unsafe_ip(self) -> ShapelyPoint: + unthreatened_home_zone = self.home_bubble.difference(self.threat_zone) + if unthreatened_home_zone.is_empty: + # Nowhere in our home zone is safe. The package will need to exit the + # threatened area to hold and rendezvous. Pick the IP closest to the + # edge of the threat zone. + return shapely.ops.nearest_points( + self.permissible_zone, self.threat_zone.boundary + )[0] + + # No safe point in the IP zone, but the home zone is safe. Pick the max- + # distance IP that's closest to the untreatened home zone. + return shapely.ops.nearest_points( + self.permissible_zone, unthreatened_home_zone + )[0] + + def _safe_ip(self) -> ShapelyPoint: + # We have a zone of possible IPs that are safe, close enough, and in range. Pick + # the IP in the zone that's closest to the target. + return shapely.ops.nearest_points(self.safe_zone, self.home)[0] + + def find_best_ip(self) -> Point: + if self.safe_zone.is_empty: + ip = self._unsafe_ip() + else: + ip = self._safe_ip() + return Point(ip.x, ip.y) diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py index 0927b968..13295d01 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -20,6 +20,7 @@ from dcs.unit import Unit from shapely.geometry import Point as ShapelyPoint from game.data.doctrine import Doctrine +from game.flightplan import IpZoneGeometry from game.theater import ( Airfield, ControlPoint, @@ -946,57 +947,35 @@ class FlightPlanBuilder: raise PlanningError(f"{task} flight plan generation not implemented") def regenerate_package_waypoints(self) -> None: - # The simple case is where the target is not near the departure airfield. In - # this case, we can plan the shortest route from the departure airfield to the - # target, use the nearest non-threatened point *that's farther from the target - # than the ingress point to avoid backtracking) as the join point. - # - # The other case that we need to handle is when the target is close to - # the origin airfield. In this case we currently fall back to the old planning - # behavior. - # - # A messy (and very unlikely) case that we can't do much about: - # - # +--------------+ +---------------+ - # | | | | - # | IP-+---+-T | - # | | | | - # | | | | - # +--------------+ +---------------+ from gen.ato import PackageWaypoints - target = self.package.target.position + # Start by picking the best IP for the attack. + ingress_point = IpZoneGeometry( + self.package.target.position, + self.package_airfield().position, + self.coalition, + ).find_best_ip() - for join_point in self.preferred_join_points(): - join_distance = meters(join_point.distance_to_point(target)) - if join_distance > self.doctrine.ingress_distance: - break - else: + # Pick the join point based on the best route to the IP. + join_point = self.preferred_join_point(ingress_point) + if join_point is None: # The entire path to the target is threatened. Use the fallback behavior for # now. - self.legacy_package_waypoints_impl() + self.legacy_package_waypoints_impl(ingress_point) return - attack_heading = join_point.heading_between_point(target) - ingress_point = self._ingress_point(attack_heading) - - # The first case described above. The ingress and join points are placed - # reasonably relative to each other. + # And the split point based on the best route from the IP. Since that's no + # different than the best route *to* the IP, this is the same as the join point. + # TODO: Estimate attack completion point based on the IP and split from there? self.package.waypoints = PackageWaypoints( WaypointBuilder.perturb(join_point), ingress_point, - WaypointBuilder.perturb( - self.preferred_split_point(ingress_point, join_point) - ), + WaypointBuilder.perturb(join_point), ) - def retreat_point(self, origin: Point) -> Point: - return self.threat_zones.closest_boundary(origin) - - def legacy_package_waypoints_impl(self) -> None: + def legacy_package_waypoints_impl(self, ingress_point: Point) -> None: from gen.ato import PackageWaypoints - ingress_point = self._ingress_point(self._target_heading_to_package_airfield()) join_point = self._rendezvous_point(ingress_point) self.package.waypoints = PackageWaypoints( WaypointBuilder.perturb(join_point), @@ -1009,23 +988,15 @@ class FlightPlanBuilder: if not self.threat_zones.threatened(point): yield point - def preferred_join_points(self) -> Iterator[Point]: + def preferred_join_point(self, ingress_point: Point) -> Optional[Point]: # Use non-threatened points along the path to the target as the join point. We # may need to try more than one in the event that the close non-threatened # points are closer than the ingress point itself. - return self.safe_points_between( - self.package.target.position, self.package_airfield().position - ) - - def preferred_split_point(self, ingress_point: Point, join_point: Point) -> Point: - # Use non-threatened points along the path to the target as the join point. We - # may need to try more than one in the event that the close non-threatened - # points are closer than the ingress point itself. - for point in self.safe_points_between( + for join_point in self.safe_points_between( ingress_point, self.package_airfield().position ): - return point - return join_point + return join_point + return None def generate_strike(self, flight: Flight) -> StrikeFlightPlan: """Generates a strike flight plan. @@ -1847,7 +1818,7 @@ class FlightPlanBuilder: def _ingress_point(self, heading: float) -> Point: return self.package.target.position.point_from_heading( - heading - 180, self.doctrine.ingress_distance.meters + heading - 180, self.doctrine.max_ingress_distance.meters ) def _target_heading_to_package_airfield(self) -> float: diff --git a/qt_ui/widgets/map/mapmodel.py b/qt_ui/widgets/map/mapmodel.py index 02eadd9f..c2e6706a 100644 --- a/qt_ui/widgets/map/mapmodel.py +++ b/qt_ui/widgets/map/mapmodel.py @@ -27,7 +27,12 @@ from game.transfers import MultiGroupTransport, TransportMap from game.utils import meters, nautical_miles from gen.ato import AirTaskingOrder from gen.flights.flight import Flight, FlightWaypoint, FlightWaypointType -from gen.flights.flightplan import FlightPlan, PatrollingFlightPlan, CasFlightPlan +from gen.flights.flightplan import ( + FlightPlan, + PatrollingFlightPlan, + CasFlightPlan, +) +from game.flightplan.ipzonegeometry import IpZoneGeometry from qt_ui.dialogs import Dialog from qt_ui.models import GameModel, AtoModel from qt_ui.windows.GameUpdateSignal import GameUpdateSignal @@ -39,6 +44,10 @@ LeafletPoly = list[LeafletLatLon] MAX_SHIP_DISTANCE = nautical_miles(80) +# Set to True to enable computing expensive debugging information. At the time of +# writing this only controls computing the waypoint placement zones. +ENABLE_EXPENSIVE_DEBUG_TOOLS = False + # **EVERY PROPERTY NEEDS A NOTIFY SIGNAL** # # https://bugreports.qt.io/browse/PYSIDE-1426 @@ -512,6 +521,19 @@ class FlightJs(QObject): selectedChanged = Signal() commitBoundaryChanged = Signal() + originChanged = Signal() + + @Property(list, notify=originChanged) + def origin(self) -> LeafletLatLon: + return self._waypoints[0].position + + targetChanged = Signal() + + @Property(list, notify=targetChanged) + def target(self) -> LeafletLatLon: + ll = self.theater.point_to_ll(self.flight.package.target.position) + return [ll.latitude, ll.longitude] + def __init__( self, flight: Flight, @@ -769,6 +791,56 @@ class UnculledZone(QObject): ) +class IpZonesJs(QObject): + homeBubbleChanged = Signal() + ipBubbleChanged = Signal() + permissibleZoneChanged = Signal() + safeZoneChanged = Signal() + + def __init__( + self, + home_bubble: list[LeafletPoly], + ip_bubble: list[LeafletPoly], + permissible_zone: list[LeafletPoly], + safe_zone: list[LeafletPoly], + ) -> None: + super().__init__() + self._home_bubble = home_bubble + self._ip_bubble = ip_bubble + self._permissible_zone = permissible_zone + self._safe_zone = safe_zone + + @Property(list, notify=homeBubbleChanged) + def homeBubble(self) -> list[LeafletPoly]: + return self._home_bubble + + @Property(list, notify=ipBubbleChanged) + def ipBubble(self) -> list[LeafletPoly]: + return self._ip_bubble + + @Property(list, notify=permissibleZoneChanged) + def permissibleZone(self) -> list[LeafletPoly]: + return self._permissible_zone + + @Property(list, notify=permissibleZoneChanged) + def safeZone(self) -> list[LeafletPoly]: + return self._safe_zone + + @classmethod + def for_flight(cls, flight: Flight, game: Game) -> IpZonesJs: + target = flight.package.target + home = flight.departure + if not ENABLE_EXPENSIVE_DEBUG_TOOLS or target == home: + return IpZonesJs([], [], [], []) + geometry = IpZoneGeometry(target.position, home.position, game.blue) + return IpZonesJs( + shapely_to_leaflet_polys(geometry.home_bubble, game.theater), + shapely_to_leaflet_polys(geometry.ip_bubble, game.theater), + shapely_to_leaflet_polys(geometry.permissible_zone, game.theater), + shapely_to_leaflet_polys(geometry.safe_zone, game.theater), + ) + + class MapModel(QObject): cleared = Signal() @@ -782,6 +854,7 @@ class MapModel(QObject): navmeshesChanged = Signal() mapZonesChanged = Signal() unculledZonesChanged = Signal() + ipZonesChanged = Signal() def __init__(self, game_model: GameModel) -> None: super().__init__() @@ -798,6 +871,7 @@ class MapModel(QObject): self._navmeshes = NavMeshJs([], []) self._map_zones = MapZonesJs([], [], []) self._unculled_zones = [] + self._ip_zones = IpZonesJs([], [], [], []) self._selected_flight_index: Optional[Tuple[int, int]] = None GameUpdateSignal.get_instance().game_loaded.connect(self.on_game_load) GameUpdateSignal.get_instance().flight_paths_changed.connect(self.reset_atos) @@ -821,6 +895,7 @@ class MapModel(QObject): self._navmeshes = NavMeshJs([], []) self._map_zones = MapZonesJs([], [], []) self._unculled_zones = [] + self._ip_zones = IpZonesJs([], [], [], []) self.cleared.emit() def set_package_selection(self, index: int) -> None: @@ -896,11 +971,24 @@ class MapModel(QObject): ) return flights + def _get_selected_flight(self) -> Optional[Flight]: + for p_idx, package in enumerate(self.game.blue.ato.packages): + for f_idx, flight in enumerate(package.flights): + if (p_idx, f_idx) == self._selected_flight_index: + return flight + return None + def reset_atos(self) -> None: self._flights = self._flights_in_ato( self.game.blue.ato, blue=True ) + self._flights_in_ato(self.game.red.ato, blue=False) self.flightsChanged.emit() + selected_flight = self._get_selected_flight() + if selected_flight is not None: + self._ip_zones = IpZonesJs.for_flight(selected_flight, self.game) + else: + self._ip_zones = IpZonesJs([], [], [], []) + self.ipZonesChanged.emit() @Property(list, notify=flightsChanged) def flights(self) -> List[FlightJs]: @@ -1029,6 +1117,10 @@ class MapModel(QObject): def unculledZones(self) -> list[UnculledZone]: return self._unculled_zones + @Property(IpZonesJs, notify=ipZonesChanged) + def ipZones(self) -> IpZonesJs: + return self._ip_zones + @property def game(self) -> Game: if self.game_model.game is None: diff --git a/resources/ui/map/map.js b/resources/ui/map/map.js index 6cae9ae2..e56a9fc0 100644 --- a/resources/ui/map/map.js +++ b/resources/ui/map/map.js @@ -1,3 +1,7 @@ +// Won't actually enable anything unless the same property is set in +// mapmodel.py. +const ENABLE_EXPENSIVE_DEBUG_TOOLS = false; + const Colors = Object.freeze({ Blue: "#0084ff", Red: "#c85050", @@ -124,26 +128,26 @@ const map = L.map("map", { L.control.scale({ maxWidth: 200 }).addTo(map); const rulerOptions = { - position: 'topleft', + position: "topleft", circleMarker: { color: Colors.Highlight, - radius: 2 + radius: 2, }, lineStyle: { color: Colors.Highlight, - dashArray: '1,6' + dashArray: "1,6", }, lengthUnit: { display: "nm", decimal: "2", factor: 0.539956803, - label: "Distance:" + label: "Distance:", }, angleUnit: { - display: '°', + display: "°", decimal: 0, - label: "Bearing:" - } + label: "Bearing:", + }, }; L.control.ruler(rulerOptions).addTo(map); @@ -194,6 +198,48 @@ const exclusionZones = L.layerGroup(); const seaZones = L.layerGroup(); const unculledZones = L.layerGroup(); +const homeBubble = L.layerGroup(); +const ipBubble = L.layerGroup(); +const permissibleZone = L.layerGroup(); +const safeZone = L.layerGroup(); + +const debugControlGroups = { + "Blue Threat Zones": { + Hide: L.layerGroup().addTo(map), + Full: blueFullThreatZones, + Aircraft: blueAircraftThreatZones, + "Air Defenses": blueAirDefenseThreatZones, + "Radar SAMs": blueRadarSamThreatZones, + }, + "Red Threat Zones": { + Hide: L.layerGroup().addTo(map), + Full: redFullThreatZones, + Aircraft: redAircraftThreatZones, + "Air Defenses": redAirDefenseThreatZones, + "Radar SAMs": redRadarSamThreatZones, + }, + Navmeshes: { + Hide: L.layerGroup().addTo(map), + Blue: blueNavmesh, + Red: redNavmesh, + }, + "Map Zones": { + "Inclusion zones": inclusionZones, + "Exclusion zones": exclusionZones, + "Sea zones": seaZones, + "Culling exclusion zones": unculledZones, + }, +}; + +if (ENABLE_EXPENSIVE_DEBUG_TOOLS) { + debugControlGroups["IP Zones"] = { + "Home bubble": homeBubble, + "IP bubble": ipBubble, + "Permissible zone": permissibleZone, + "Safe zone": safeZone, + }; +} + // Main map controls. These are the ones that we expect users to interact with. // These are always open, which unfortunately means that the scroll bar will not // appear if the menu doesn't fit. This fits in the smallest window size we @@ -239,41 +285,11 @@ L.control // Debug map controls. Hover over to open. Not something most users will want or // need to interact with. L.control - .groupedLayers( - null, - { - "Blue Threat Zones": { - Hide: L.layerGroup().addTo(map), - Full: blueFullThreatZones, - Aircraft: blueAircraftThreatZones, - "Air Defenses": blueAirDefenseThreatZones, - "Radar SAMs": blueRadarSamThreatZones, - }, - "Red Threat Zones": { - Hide: L.layerGroup().addTo(map), - Full: redFullThreatZones, - Aircraft: redAircraftThreatZones, - "Air Defenses": redAirDefenseThreatZones, - "Radar SAMs": redRadarSamThreatZones, - }, - Navmeshes: { - Hide: L.layerGroup().addTo(map), - Blue: blueNavmesh, - Red: redNavmesh, - }, - "Map Zones": { - "Inclusion zones": inclusionZones, - "Exclusion zones": exclusionZones, - "Sea zones": seaZones, - "Culling exclusion zones": unculledZones, - }, - }, - { - position: "topleft", - exclusiveGroups: ["Blue Threat Zones", "Red Threat Zones", "Navmeshes"], - groupCheckboxes: true, - } - ) + .groupedLayers(null, debugControlGroups, { + position: "topleft", + exclusiveGroups: ["Blue Threat Zones", "Red Threat Zones", "Navmeshes"], + groupCheckboxes: true, + }) .addTo(map); let game; @@ -291,6 +307,7 @@ new QWebChannel(qt.webChannelTransport, function (channel) { game.navmeshesChanged.connect(drawNavmeshes); game.mapZonesChanged.connect(drawMapZones); game.unculledZonesChanged.connect(drawUnculledZones); + game.ipZonesChanged.connect(drawIpZones); }); function recenterMap(center) { @@ -570,7 +587,11 @@ class TheaterGroundObject { } L.marker(this.tgo.position, { icon: this.icon() }) - .bindTooltip(`${this.tgo.name} (${this.tgo.controlPointName})
${this.tgo.units.join("
")}`) + .bindTooltip( + `${this.tgo.name} (${ + this.tgo.controlPointName + })
${this.tgo.units.join("
")}` + ) .on("click", () => this.tgo.showInfoDialog()) .on("contextmenu", () => this.tgo.showPackageDialog()) .addTo(this.layer()); @@ -970,6 +991,37 @@ function drawUnculledZones() { } } +function drawIpZones() { + homeBubble.clearLayers(); + ipBubble.clearLayers(); + permissibleZone.clearLayers(); + safeZone.clearLayers(); + + L.polygon(game.ipZones.homeBubble, { + color: Colors.Highlight, + fillOpacity: 0.1, + interactive: false, + }).addTo(homeBubble); + + L.polygon(game.ipZones.ipBubble, { + color: "#bb89ff", + fillOpacity: 0.1, + interactive: false, + }).addTo(ipBubble); + + L.polygon(game.ipZones.permissibleZone, { + color: "#ffffff", + fillOpacity: 0.1, + interactive: false, + }).addTo(permissibleZone); + + L.polygon(game.ipZones.safeZone, { + color: Colors.Green, + fillOpacity: 0.1, + interactive: false, + }).addTo(safeZone); +} + function drawInitialMap() { recenterMap(game.mapCenter); drawControlPoints(); @@ -981,6 +1033,7 @@ function drawInitialMap() { drawNavmeshes(); drawMapZones(); drawUnculledZones(); + drawIpZones(); } function clearAllLayers() {