diff --git a/client/src/api/_liberationApi.ts b/client/src/api/_liberationApi.ts
index b7de3303..8f527a49 100644
--- a/client/src/api/_liberationApi.ts
+++ b/client/src/api/_liberationApi.ts
@@ -50,14 +50,6 @@ const injectedRtkApi = api.injectEndpoints({
url: `/debug/waypoint-geometries/hold/${queryArg.flightId}`,
}),
}),
- getDebugIpZones: build.query<
- GetDebugIpZonesApiResponse,
- GetDebugIpZonesApiArg
- >({
- query: (queryArg) => ({
- url: `/debug/waypoint-geometries/ip/${queryArg.flightId}`,
- }),
- }),
getDebugJoinZones: build.query<
GetDebugJoinZonesApiResponse,
GetDebugJoinZonesApiArg
@@ -245,11 +237,6 @@ export type GetDebugHoldZonesApiResponse =
export type GetDebugHoldZonesApiArg = {
flightId: string;
};
-export type GetDebugIpZonesApiResponse =
- /** status 200 Successful Response */ IpZones;
-export type GetDebugIpZonesApiArg = {
- flightId: string;
-};
export type GetDebugJoinZonesApiResponse =
/** status 200 Successful Response */ JoinZones;
export type GetDebugJoinZonesApiArg = {
@@ -379,14 +366,6 @@ export type HoldZones = {
permissibleZones: LatLng[][][];
preferredLines: LatLng[][];
};
-export type IpZones = {
- homeBubble: LatLng[][];
- ipBubble: LatLng[][];
- permissibleZone: LatLng[][];
- safeZones: LatLng[][][];
- preferredThreatenedZones: LatLng[][][];
- tolerableThreatenedLines: LatLng[][];
-};
export type JoinZones = {
homeBubble: LatLng[][];
targetBubble: LatLng[][];
@@ -500,7 +479,6 @@ export const {
useSetControlPointDestinationMutation,
useClearControlPointDestinationMutation,
useGetDebugHoldZonesQuery,
- useGetDebugIpZonesQuery,
useGetDebugJoinZonesQuery,
useListFlightsQuery,
useGetFlightByIdQuery,
diff --git a/client/src/api/liberationApi.ts b/client/src/api/liberationApi.ts
index 84a58377..165e0d2b 100644
--- a/client/src/api/liberationApi.ts
+++ b/client/src/api/liberationApi.ts
@@ -30,11 +30,6 @@ export const liberationApi = _liberationApi.enhanceEndpoints({
{ type: Tags.FLIGHT_PLAN, id: arg.flightId },
],
},
- getDebugIpZones: {
- providesTags: (result, error, arg) => [
- { type: Tags.FLIGHT_PLAN, id: arg.flightId },
- ],
- },
getDebugJoinZones: {
providesTags: (result, error, arg) => [
{ type: Tags.FLIGHT_PLAN, id: arg.flightId },
diff --git a/client/src/components/waypointdebugzones/IpZones.tsx b/client/src/components/waypointdebugzones/IpZones.tsx
deleted file mode 100644
index fe93e1b5..00000000
--- a/client/src/components/waypointdebugzones/IpZones.tsx
+++ /dev/null
@@ -1,96 +0,0 @@
-import { useGetDebugIpZonesQuery } from "../../api/liberationApi";
-import { LayerGroup, Polygon, Polyline } from "react-leaflet";
-
-interface IpZonesProps {
- flightId: string;
-}
-
-function IpZones(props: IpZonesProps) {
- const { data, error, isLoading } = useGetDebugIpZonesQuery({
- flightId: props.flightId,
- });
-
- if (isLoading) {
- return <>>;
- }
-
- if (error) {
- console.error("Error while loading waypoint IP zone info", error);
- return <>>;
- }
-
- if (!data) {
- console.log("Waypoint IP zone returned empty response");
- return <>>;
- }
-
- return (
- <>
-
-
-
-
- {data.safeZones.map((zone, idx) => {
- return (
-
- );
- })}
-
- {data.preferredThreatenedZones.map((zone, idx) => {
- return (
-
- );
- })}
-
- {data.tolerableThreatenedLines.map((line, idx) => {
- return (
-
- );
- })}
- >
- );
-}
-
-interface IpZonesLayerProps {
- flightId: string | null;
-}
-
-export function IpZonesLayer(props: IpZonesLayerProps) {
- return (
-
- {props.flightId ? : <>>}
-
- );
-}
diff --git a/client/src/components/waypointdebugzones/WaypointDebugZonesControls.tsx b/client/src/components/waypointdebugzones/WaypointDebugZonesControls.tsx
index 7982fda4..8c08f7f1 100644
--- a/client/src/components/waypointdebugzones/WaypointDebugZonesControls.tsx
+++ b/client/src/components/waypointdebugzones/WaypointDebugZonesControls.tsx
@@ -1,7 +1,6 @@
import { selectSelectedFlightId } from "../../api/flightsSlice";
import { useAppSelector } from "../../app/hooks";
import { HoldZonesLayer } from "./HoldZones";
-import { IpZonesLayer } from "./IpZones";
import { JoinZonesLayer } from "./JoinZones";
import { LayersControl } from "react-leaflet";
@@ -16,9 +15,6 @@ export function WaypointDebugZonesControls() {
return (
<>
-
-
-
diff --git a/game/ato/flightplans/ibuilder.py b/game/ato/flightplans/ibuilder.py
index 635cac93..a1b6c905 100644
--- a/game/ato/flightplans/ibuilder.py
+++ b/game/ato/flightplans/ibuilder.py
@@ -44,7 +44,11 @@ class IBuilder(ABC, Generic[FlightPlanT, LayoutT]):
) from ex
def _generate_package_waypoints_if_needed(self) -> None:
- if self.package.waypoints is None:
+ # Package waypoints are only valid for offensive missions. Skip this if the
+ # target is friendly.
+ if self.package.waypoints is None and not self.package.target.is_friendly(
+ self.is_player
+ ):
self.package.waypoints = PackageWaypoints.create(
self.package, self.coalition
)
diff --git a/game/ato/packagewaypoints.py b/game/ato/packagewaypoints.py
index fba3d2eb..c890bfc5 100644
--- a/game/ato/packagewaypoints.py
+++ b/game/ato/packagewaypoints.py
@@ -2,13 +2,15 @@ from __future__ import annotations
import random
from dataclasses import dataclass
-from typing import TYPE_CHECKING, Optional
+from typing import TYPE_CHECKING
from dcs import Point
from game.ato.flightplans.waypointbuilder import WaypointBuilder
-from game.flightplan import IpZoneGeometry, JoinZoneGeometry
+from game.flightplan import JoinZoneGeometry
+from game.flightplan.ipsolver import IpSolver
from game.flightplan.refuelzonegeometry import RefuelZoneGeometry
+from game.utils import dcs_to_shapely_point
from game.utils import nautical_miles
if TYPE_CHECKING:
@@ -29,11 +31,15 @@ class PackageWaypoints:
origin = package.departure_closest_to_target()
# Start by picking the best IP for the attack.
- ingress_point = IpZoneGeometry(
- package.target.position,
- origin.position,
- coalition,
- ).find_best_ip()
+ ingress_point_shapely = IpSolver(
+ dcs_to_shapely_point(origin.position),
+ dcs_to_shapely_point(package.target.position),
+ coalition.doctrine,
+ coalition.opponent.threat_zone.all,
+ ).solve()
+ ingress_point = origin.position.new_in_same_map(
+ ingress_point_shapely.x, ingress_point_shapely.y
+ )
hdg = package.target.position.heading_between_point(ingress_point)
# Generate a waypoint randomly between 7 & 9 NM
diff --git a/game/flightplan/__init__.py b/game/flightplan/__init__.py
index 17a92708..4e57dff5 100644
--- a/game/flightplan/__init__.py
+++ b/game/flightplan/__init__.py
@@ -1,3 +1,2 @@
from .holdzonegeometry import HoldZoneGeometry
-from .ipzonegeometry import IpZoneGeometry
from .joinzonegeometry import JoinZoneGeometry
diff --git a/game/flightplan/ipsolver.py b/game/flightplan/ipsolver.py
new file mode 100644
index 00000000..e4cb943d
--- /dev/null
+++ b/game/flightplan/ipsolver.py
@@ -0,0 +1,173 @@
+from __future__ import annotations
+
+from collections.abc import Iterator
+from typing import Any
+
+from shapely.geometry import MultiPolygon, Point
+from shapely.geometry.base import BaseGeometry
+
+from game.data.doctrine import Doctrine
+from game.flightplan.waypointsolver import WaypointSolver
+from game.flightplan.waypointstrategy import WaypointStrategy
+from game.utils import meters, nautical_miles
+
+MIN_DISTANCE_FROM_DEPARTURE = nautical_miles(5)
+
+
+class ThreatTolerantIpStrategy(WaypointStrategy):
+ def __init__(
+ self,
+ departure: Point,
+ target: Point,
+ doctrine: Doctrine,
+ threat_zones: MultiPolygon,
+ ) -> None:
+ super().__init__(threat_zones)
+ self.prerequisite(target).min_distance_from(
+ departure, doctrine.min_ingress_distance
+ )
+ self.require().at_least(MIN_DISTANCE_FROM_DEPARTURE).away_from(departure)
+ self.require().at_most(meters(departure.distance(target))).away_from(departure)
+ self.require().at_least(doctrine.min_ingress_distance).away_from(target)
+ max_ip_range = min(
+ doctrine.max_ingress_distance, meters(departure.distance(target))
+ )
+ self.require().at_most(max_ip_range).away_from(target)
+ self.threat_tolerance(target, max_ip_range, nautical_miles(5))
+ self.nearest(departure)
+
+
+class UnsafeIpStrategy(WaypointStrategy):
+ def __init__(
+ self,
+ departure: Point,
+ target: Point,
+ doctrine: Doctrine,
+ threat_zones: MultiPolygon,
+ ) -> None:
+ super().__init__(threat_zones)
+ self.prerequisite(target).min_distance_from(
+ departure, doctrine.min_ingress_distance
+ )
+ self.require().at_least(MIN_DISTANCE_FROM_DEPARTURE).away_from(
+ departure, "departure"
+ )
+ self.require().at_most(meters(departure.distance(target))).away_from(
+ departure, "departure"
+ )
+ self.require().at_least(doctrine.min_ingress_distance).away_from(
+ target, "target"
+ )
+ max_ip_range = min(
+ doctrine.max_ingress_distance, meters(departure.distance(target))
+ )
+ self.require().at_most(max_ip_range).away_from(target, "target")
+ self.nearest(departure)
+
+
+class SafeIpStrategy(WaypointStrategy):
+ def __init__(
+ self,
+ departure: Point,
+ target: Point,
+ doctrine: Doctrine,
+ threat_zones: MultiPolygon,
+ ) -> None:
+ super().__init__(threat_zones)
+ self.prerequisite(departure).is_safe()
+ self.prerequisite(target).min_distance_from(
+ departure, doctrine.min_ingress_distance
+ )
+ self.require().at_least(MIN_DISTANCE_FROM_DEPARTURE).away_from(
+ departure, "departure"
+ )
+ self.require().at_most(meters(departure.distance(target))).away_from(
+ departure, "departure"
+ )
+ self.require().at_least(doctrine.min_ingress_distance).away_from(
+ target, "target"
+ )
+ self.require().at_most(
+ min(doctrine.max_ingress_distance, meters(departure.distance(target)))
+ ).away_from(target, "target")
+ self.require().safe()
+ self.nearest(departure)
+
+
+class SafeBackTrackingIpStrategy(WaypointStrategy):
+ def __init__(
+ self,
+ departure: Point,
+ target: Point,
+ doctrine: Doctrine,
+ threat_zones: MultiPolygon,
+ ) -> None:
+ super().__init__(threat_zones)
+ self.require().at_least(MIN_DISTANCE_FROM_DEPARTURE).away_from(
+ departure, "departure"
+ )
+ self.require().at_least(doctrine.min_ingress_distance).away_from(
+ target, "target"
+ )
+ self.require().at_most(doctrine.max_ingress_distance).away_from(
+ target, "target"
+ )
+ self.require().safe()
+ self.nearest(departure)
+
+
+class UnsafeBackTrackingIpStrategy(WaypointStrategy):
+ def __init__(
+ self,
+ departure: Point,
+ target: Point,
+ doctrine: Doctrine,
+ threat_zones: MultiPolygon,
+ ) -> None:
+ super().__init__(threat_zones)
+ self.require().at_least(MIN_DISTANCE_FROM_DEPARTURE).away_from(
+ departure, "departure"
+ )
+ self.require().at_least(doctrine.min_ingress_distance).away_from(
+ target, "target"
+ )
+ self.require().at_most(doctrine.max_ingress_distance).away_from(
+ target, "target"
+ )
+ self.nearest(departure)
+
+
+class IpSolver(WaypointSolver):
+ def __init__(
+ self,
+ departure: Point,
+ target: Point,
+ doctrine: Doctrine,
+ threat_zones: MultiPolygon,
+ ) -> None:
+ super().__init__()
+ self.departure = departure
+ self.target = target
+ self.doctrine = doctrine
+ self.threat_zones = threat_zones
+
+ self.add_strategy(SafeIpStrategy(departure, target, doctrine, threat_zones))
+ self.add_strategy(
+ ThreatTolerantIpStrategy(departure, target, doctrine, threat_zones)
+ )
+ self.add_strategy(UnsafeIpStrategy(departure, target, doctrine, threat_zones))
+ self.add_strategy(
+ SafeBackTrackingIpStrategy(departure, target, doctrine, threat_zones)
+ )
+ # TODO: The cases that require this are not covered by any tests.
+ self.add_strategy(
+ UnsafeBackTrackingIpStrategy(departure, target, doctrine, threat_zones)
+ )
+
+ def describe_metadata(self) -> dict[str, Any]:
+ return {"doctrine": self.doctrine.name}
+
+ def describe_inputs(self) -> Iterator[tuple[str, BaseGeometry]]:
+ yield "departure", self.departure
+ yield "target", self.target
+ yield "threat_zones", self.threat_zones
diff --git a/game/flightplan/ipzonegeometry.py b/game/flightplan/ipzonegeometry.py
deleted file mode 100644
index 3d8ea923..00000000
--- a/game/flightplan/ipzonegeometry.py
+++ /dev/null
@@ -1,165 +0,0 @@
-from __future__ import annotations
-
-from typing import TYPE_CHECKING
-
-import shapely.ops
-from dcs import Point
-from shapely.geometry import MultiPolygon, Point as ShapelyPoint, MultiLineString
-
-from game.utils import meters, nautical_miles
-
-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._target = target
- 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
-
- safe_zones = self.permissible_zone.difference(
- self.threat_zone.buffer(attack_distance_buffer.meters)
- )
-
- if not isinstance(safe_zones, MultiPolygon):
- 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:
- # 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 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]
-
- 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_zones, self.home)[0]
-
- def find_best_ip(self) -> Point:
- if self.safe_zones.is_empty:
- ip = self._unsafe_ip()
- else:
- ip = self._safe_ip()
- return self._target.new_in_same_map(ip.x, ip.y)
diff --git a/game/server/debuggeometries/models.py b/game/server/debuggeometries/models.py
index 93ecd166..5d60c823 100644
--- a/game/server/debuggeometries/models.py
+++ b/game/server/debuggeometries/models.py
@@ -4,7 +4,7 @@ from pydantic import BaseModel, Field
from game import Game
from game.ato import Flight
-from game.flightplan import HoldZoneGeometry, IpZoneGeometry, JoinZoneGeometry
+from game.flightplan import HoldZoneGeometry, JoinZoneGeometry
from ..leaflet import LeafletLine, LeafletPoly, ShapelyUtil
@@ -59,42 +59,6 @@ class HoldZonesJs(BaseModel):
)
-class IpZonesJs(BaseModel):
- home_bubble: LeafletPoly = Field(alias="homeBubble")
- 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 for_flight(cls, flight: Flight, game: Game) -> IpZonesJs:
- target = flight.package.target
- home = flight.departure
- geometry = IpZoneGeometry(target.position, home.position, game.blue)
- return IpZonesJs(
- homeBubble=ShapelyUtil.poly_to_leaflet(geometry.home_bubble, game.theater),
- ipBubble=ShapelyUtil.poly_to_leaflet(geometry.ip_bubble, game.theater),
- permissibleZone=ShapelyUtil.poly_to_leaflet(
- 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
- ),
- )
-
-
class JoinZonesJs(BaseModel):
home_bubble: LeafletPoly = Field(alias="homeBubble")
target_bubble: LeafletPoly = Field(alias="targetBubble")
diff --git a/game/server/debuggeometries/routes.py b/game/server/debuggeometries/routes.py
index b85b4250..f379b995 100644
--- a/game/server/debuggeometries/routes.py
+++ b/game/server/debuggeometries/routes.py
@@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends
from game import Game
from game.server import GameContext
-from .models import HoldZonesJs, IpZonesJs, JoinZonesJs
+from .models import HoldZonesJs, JoinZonesJs
router: APIRouter = APIRouter(prefix="/debug/waypoint-geometries")
@@ -18,13 +18,6 @@ def hold_zones(
return HoldZonesJs.for_flight(game.db.flights.get(flight_id), game)
-@router.get(
- "/ip/{flight_id}", operation_id="get_debug_ip_zones", response_model=IpZonesJs
-)
-def ip_zones(flight_id: UUID, game: Game = Depends(GameContext.require)) -> IpZonesJs:
- return IpZonesJs.for_flight(game.db.flights.get(flight_id), game)
-
-
@router.get(
"/join/{flight_id}", operation_id="get_debug_join_zones", response_model=JoinZonesJs
)