diff --git a/changelog.md b/changelog.md index 9b02d884..6338d8ad 100644 --- a/changelog.md +++ b/changelog.md @@ -39,6 +39,7 @@ * **[Mission Generation]** Automatic datalink network setup for applicable aircraft (_should_ in theory avoid the need to re-save the mission) * **[Options]** New option to force-enable deck-crew for super-carriers on dedicated server. * **[Mission Generation]** Enable Supercarrier's LSO & Airboss stations +* **[Autoplanner]** Plan Air-to-Air Escorts for AWACS & Tankers ## Fixes * **[UI/UX]** A-10A flights can be edited again diff --git a/game/ato/airtaaskingorder.py b/game/ato/airtaaskingorder.py index fca03835..290d2014 100644 --- a/game/ato/airtaaskingorder.py +++ b/game/ato/airtaaskingorder.py @@ -1,6 +1,7 @@ from dataclasses import dataclass, field from typing import List +from game.ato import FlightType from game.ato.package import Package @@ -11,6 +12,16 @@ class AirTaskingOrder: #: The set of all planned packages in the ATO. packages: List[Package] = field(default_factory=list) + @property + def has_awacs_package(self) -> bool: + return any( + [ + p + for p in self.packages + if any([f for f in p.flights if f.flight_type is FlightType.AEWC]) + ] + ) + def add_package(self, package: Package) -> None: """Adds a package to the ATO.""" self.packages.append(package) diff --git a/game/ato/flightplans/escort.py b/game/ato/flightplans/escort.py index bdae0ac1..d4378f13 100644 --- a/game/ato/flightplans/escort.py +++ b/game/ato/flightplans/escort.py @@ -11,6 +11,7 @@ from .formationattack import ( ) from .waypointbuilder import WaypointBuilder from .. import FlightType +from ..packagewaypoints import PackageWaypoints from ...utils import feet @@ -22,16 +23,28 @@ class EscortFlightPlan(FormationAttackFlightPlan): class Builder(FormationAttackBuilder[EscortFlightPlan, FormationAttackLayout]): def layout(self) -> FormationAttackLayout: - assert self.package.waypoints is not None + non_formation_escort = False + if self.package.waypoints is None: + self.package.waypoints = PackageWaypoints.create( + self.package, self.coalition, dump_debug_info=False + ) + if self.package.primary_flight: + departure = self.package.primary_flight.flight_plan.layout.departure + self.package.waypoints.join = departure.position.lerp( + self.package.target.position, 0.2 + ) + non_formation_escort = True builder = WaypointBuilder(self.flight) ingress, target = builder.escort( self.package.waypoints.ingress, self.package.target ) + if non_formation_escort: + target.position = self.package.waypoints.join ingress.only_for_player = True target.only_for_player = True hold = None - if not self.flight.is_helo: + if not (self.flight.is_helo or non_formation_escort): hold = builder.hold(self._hold_point()) join_pos = ( diff --git a/game/commander/packagebuilder.py b/game/commander/packagebuilder.py index b2694959..05fd0386 100644 --- a/game/commander/packagebuilder.py +++ b/game/commander/packagebuilder.py @@ -4,6 +4,7 @@ from typing import Optional, TYPE_CHECKING from game.theater import ControlPoint, MissionTarget, OffMapSpawn from game.utils import nautical_miles +from ..ato import FlightType from ..ato.flight import Flight from ..ato.package import Package from ..ato.starttype import StartType @@ -46,10 +47,18 @@ class PackageBuilder: caller should return any previously planned flights to the inventory using release_planned_aircraft. """ + target = self.package.target + heli = False pf = self.package.primary_flight - heli = pf.is_helo if pf else False + if pf: + target = ( + pf.departure + if pf.flight_type in [FlightType.AEWC, FlightType.REFUELING] + else target + ) + heli = pf.is_helo squadron = self.air_wing.best_squadron_for( - self.package.target, + target, plan.task, plan.num_aircraft, heli, diff --git a/game/commander/packagefulfiller.py b/game/commander/packagefulfiller.py index 95e0d9a9..71ddef62 100644 --- a/game/commander/packagefulfiller.py +++ b/game/commander/packagefulfiller.py @@ -83,12 +83,19 @@ class PackageFulfiller: purchase_multiplier: int, ignore_range: bool = False, ) -> None: + target = mission.location + pf = builder.package.primary_flight + if ( + pf + and pf.flight_type in [FlightType.AEWC, FlightType.REFUELING] + and flight.task is FlightType.ESCORT + ): + target = pf.departure if not builder.plan_flight(flight, ignore_range): - pf = builder.package.primary_flight heli = pf.is_helo if pf else False missing_types.add(flight.task) purchase_order = AircraftProcurementRequest( - near=mission.location, + near=target, task_capability=flight.task, number=flight.num_aircraft * purchase_multiplier, heli=heli, diff --git a/game/commander/tasks/packageplanningtask.py b/game/commander/tasks/packageplanningtask.py index d5f7606a..f7a8c2bb 100644 --- a/game/commander/tasks/packageplanningtask.py +++ b/game/commander/tasks/packageplanningtask.py @@ -102,8 +102,14 @@ class PackagePlanningTask(TheaterCommanderTask, Generic[MissionTargetT]): state.context.settings, ) with state.context.tracer.trace(f"{color} {self.flights[0].task} planning"): + asap = False + if ( + not state.context.coalition.ato.has_awacs_package + and FlightType.AEWC in [f.task for f in self.flights] + ): + asap = True self.package = fulfiller.plan_mission( - ProposedMission(self.target, self.flights), + ProposedMission(self.target, self.flights, asap=asap), self.purchase_multiplier, state.context.now, state.context.tracer, diff --git a/game/commander/tasks/primitive/aewc.py b/game/commander/tasks/primitive/aewc.py index 274b8393..b7c116e4 100644 --- a/game/commander/tasks/primitive/aewc.py +++ b/game/commander/tasks/primitive/aewc.py @@ -25,6 +25,7 @@ class PlanAewc(PackagePlanningTask[MissionTarget]): def propose_flights(self) -> None: self.propose_flight(FlightType.AEWC, 1) + self.propose_flight(FlightType.ESCORT, 2) @property def asap(self) -> bool: diff --git a/game/commander/tasks/primitive/refueling.py b/game/commander/tasks/primitive/refueling.py index e3213fed..d10e6d0f 100644 --- a/game/commander/tasks/primitive/refueling.py +++ b/game/commander/tasks/primitive/refueling.py @@ -25,3 +25,4 @@ class PlanRefueling(PackagePlanningTask[MissionTarget]): def propose_flights(self) -> None: self.propose_flight(FlightType.REFUELING, 1) + self.propose_flight(FlightType.ESCORT, 2) diff --git a/game/flightplan/waypointstrategy.py b/game/flightplan/waypointstrategy.py index e5a06543..4fd88e0e 100644 --- a/game/flightplan/waypointstrategy.py +++ b/game/flightplan/waypointstrategy.py @@ -6,6 +6,7 @@ from collections.abc import Iterator, Callable from dataclasses import dataclass from typing import Any +import numpy as np from dcs.mapping import heading_between_points from shapely.geometry import Point, MultiPolygon, Polygon from shapely.geometry.base import BaseGeometry as Geometry, BaseGeometry @@ -232,11 +233,12 @@ class WaypointStrategy: min_distance_from_threat_to_target_buffer = target.buffer( target_size.meters ).distance(self.threat_zones.boundary) - threat_mask = self.threat_zones.buffer( - -min_distance_from_threat_to_target_buffer - wiggle.meters - ) - self._threat_tolerance = ThreatTolerance(target, target_size, wiggle) - self.threat_zones = self.threat_zones.difference(threat_mask) + if np.isfinite(min_distance_from_threat_to_target_buffer).all(): + threat_mask = self.threat_zones.buffer( + -min_distance_from_threat_to_target_buffer - wiggle.meters + ) + self._threat_tolerance = ThreatTolerance(target, target_size, wiggle) + self.threat_zones = self.threat_zones.difference(threat_mask) def nearest(self, point: Point) -> None: if self.point_for_nearest_solution is not None: diff --git a/game/settings/settings.py b/game/settings/settings.py index 76d0588d..b808591a 100644 --- a/game/settings/settings.py +++ b/game/settings/settings.py @@ -689,6 +689,16 @@ class Settings: max=100, detail="See 2-ship weight factor (WF4)", ) + primary_task_distance_factor: int = bounded_int_option( + "Primary task distance weight (NM)", + CAMPAIGN_MANAGEMENT_PAGE, + FLIGHT_PLANNER_AUTOMATION, + default=75, + min=10, + max=250, + detail="A larger number will force the auto-planner to stick with squadrons that have a matching primary task." + " A smaller number will ignore squadrons with a matching primary task that are too far out.", + ) # Mission Generator # Gameplay diff --git a/game/squadrons/airwing.py b/game/squadrons/airwing.py index 6b14c792..1e4e7016 100644 --- a/game/squadrons/airwing.py +++ b/game/squadrons/airwing.py @@ -10,6 +10,7 @@ from .squadrondefloader import SquadronDefLoader from ..campaignloader.squadrondefgenerator import SquadronDefGenerator from ..factions.faction import Faction from ..theater import ControlPoint, MissionTarget +from ..utils import Distance if TYPE_CHECKING: from game.game import Game @@ -87,10 +88,12 @@ class AirWing: ordered, key=lambda s: ( # This looks like the opposite of what we want because False sorts - # before True. - s.primary_task != task, - best_aircraft.index(s.aircraft), - s.location.distance_to(location), + # before True. Distance is also added, + # i.e. 75NM with primary task match is similar to non-primary with 0NM to target + int(s.primary_task != task) + + Distance.from_meters(s.location.distance_to(location)).nautical_miles + / self.settings.primary_task_distance_factor + + best_aircraft.index(s.aircraft) / len(best_aircraft), ), )