diff --git a/changelog.md b/changelog.md index 5051dbfd..f89e9bfe 100644 --- a/changelog.md +++ b/changelog.md @@ -4,6 +4,7 @@ Saves from 8.x are not compatible with 9.0.0. ## Features/Improvements +* **[Flight Planning]** Improved IP selection for targets that are near the center of a threat zone. * **[Modding]** Factions can now specify the ship type to be used for cargo shipping. The Handy Wind will be used by default, but WW2 factions can pick something more appropriate. ## Fixes diff --git a/client/src/api/_liberationApi.ts b/client/src/api/_liberationApi.ts index a1c35b43..abcf3d50 100644 --- a/client/src/api/_liberationApi.ts +++ b/client/src/api/_liberationApi.ts @@ -384,6 +384,8 @@ export type IpZones = { ipBubble: LatLng[][]; permissibleZone: LatLng[][]; safeZones: LatLng[][][]; + preferredThreatenedZones: LatLng[][][]; + tolerableThreatenedLines: LatLng[][]; }; export type JoinZones = { homeBubble: LatLng[][]; diff --git a/client/src/components/waypointdebugzones/IpZones.tsx b/client/src/components/waypointdebugzones/IpZones.tsx index 39beef27..fe93e1b5 100644 --- a/client/src/components/waypointdebugzones/IpZones.tsx +++ b/client/src/components/waypointdebugzones/IpZones.tsx @@ -1,5 +1,5 @@ import { useGetDebugIpZonesQuery } from "../../api/liberationApi"; -import { LayerGroup, Polygon } from "react-leaflet"; +import { LayerGroup, Polygon, Polyline } from "react-leaflet"; interface IpZonesProps { flightId: string; @@ -56,6 +56,29 @@ function IpZones(props: IpZonesProps) { /> ); })} + + {data.preferredThreatenedZones.map((zone, idx) => { + return ( + + ); + })} + + {data.tolerableThreatenedLines.map((line, idx) => { + return ( + + ); + })} ); } diff --git a/game/flightplan/ipzonegeometry.py b/game/flightplan/ipzonegeometry.py index 70b5a072..3d8ea923 100644 --- a/game/flightplan/ipzonegeometry.py +++ b/game/flightplan/ipzonegeometry.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING import shapely.ops from dcs import Point -from shapely.geometry import MultiPolygon, Point as ShapelyPoint +from shapely.geometry import MultiPolygon, Point as ShapelyPoint, MultiLineString from game.utils import meters, nautical_miles @@ -90,6 +90,32 @@ class IpZoneGeometry: safe_zones = MultiPolygon([safe_zones]) self.safe_zones = safe_zones + # See explanation where this is used in _unsafe_ip. + # https://github.com/dcs-liberation/dcs_liberation/issues/2754 + preferred_threatened_zone_wiggle_room = nautical_miles(5) + threat_buffer_distance = self.permissible_zone.distance( + self.threat_zone.boundary + ) + preferred_threatened_zone_mask = self.threat_zone.buffer( + -threat_buffer_distance - preferred_threatened_zone_wiggle_room.meters + ) + preferred_threatened_zones = self.threat_zone.difference( + preferred_threatened_zone_mask + ) + + if not isinstance(preferred_threatened_zones, MultiPolygon): + preferred_threatened_zones = MultiPolygon([preferred_threatened_zones]) + self.preferred_threatened_zones = preferred_threatened_zones + + tolerable_threatened_lines = self.preferred_threatened_zones.intersection( + self.permissible_zone.boundary + ) + if tolerable_threatened_lines.is_empty: + tolerable_threatened_lines = MultiLineString([]) + elif not isinstance(tolerable_threatened_lines, MultiLineString): + tolerable_threatened_lines = MultiLineString([tolerable_threatened_lines]) + self.tolerable_threatened_lines = tolerable_threatened_lines + def _unsafe_ip(self) -> ShapelyPoint: unthreatened_home_zone = self.home_bubble.difference(self.threat_zone) if unthreatened_home_zone.is_empty: @@ -100,8 +126,28 @@ class IpZoneGeometry: 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. + # No safe point in the IP zone, but the home zone is safe. Pick an IP within + # both the permissible zone and preferred threatened zone that's as close to the + # unthreatened home zone as possible. This should get us a max-range IP that + # is roughly as safe as possible without unjustifiably long routes. + # + # If we do the obvious thing and pick the IP that minimizes threatened travel + # time (the IP closest to the threat boundary) and the objective is near the + # center of the threat zone (common when there is an airbase covered only by air + # defenses with shorter range than the BARCAP zone, and the target is a TGO near + # the CP), the IP could be placed such that the flight would fly all the way + # around the threat zone just to avoid a few more threatened miles of travel. To + # avoid that, we generate a set of preferred threatened areas that offer a + # trade-off between travel time and safety. + # + # https://github.com/dcs-liberation/dcs_liberation/issues/2754 + if not self.tolerable_threatened_lines.is_empty: + return shapely.ops.nearest_points( + self.tolerable_threatened_lines, self.home + )[0] + + # But if no part of the permissible zone is tolerably threatened, fall back to + # the old safety maximizing approach. return shapely.ops.nearest_points( self.permissible_zone, unthreatened_home_zone )[0] diff --git a/game/server/debuggeometries/models.py b/game/server/debuggeometries/models.py index 8dd271cf..93ecd166 100644 --- a/game/server/debuggeometries/models.py +++ b/game/server/debuggeometries/models.py @@ -64,14 +64,16 @@ class IpZonesJs(BaseModel): ipBubble: LeafletPoly = Field(alias="ipBubble") permissibleZone: LeafletPoly = Field(alias="permissibleZone") safeZones: list[LeafletPoly] = Field(alias="safeZones") + preferred_threatened_zones: list[LeafletPoly] = Field( + alias="preferredThreatenedZones" + ) + tolerable_threatened_lines: list[LeafletLine] = Field( + alias="tolerableThreatenedLines" + ) class Config: title = "IpZones" - @classmethod - def empty(cls) -> IpZonesJs: - return IpZonesJs(homeBubble=[], ipBubble=[], permissibleZone=[], safeZones=[]) - @classmethod def for_flight(cls, flight: Flight, game: Game) -> IpZonesJs: target = flight.package.target @@ -84,6 +86,12 @@ class IpZonesJs(BaseModel): geometry.permissible_zone, game.theater ), safeZones=ShapelyUtil.polys_to_leaflet(geometry.safe_zones, game.theater), + preferredThreatenedZones=ShapelyUtil.polys_to_leaflet( + geometry.preferred_threatened_zones, game.theater + ), + tolerableThreatenedLines=ShapelyUtil.lines_to_leaflet( + geometry.tolerable_threatened_lines, game.theater + ), )