diff --git a/changelog.md b/changelog.md index 2235cd6b..659c0245 100644 --- a/changelog.md +++ b/changelog.md @@ -25,6 +25,7 @@ * **[Modding]** Added support for Su-15 Flagon mod (v1.0) * **[Plugins]** Support for Carsten's Arty Spotter script * **[Modding]** Added support for SK-60 mod (v1.2.1) +* **[Mission Generation]** Introducing the Armed Recon flight plan, i.e. CAS against any Theater Ground Object ## Fixes * **[UI/UX]** A-10A flights can be edited again diff --git a/game/ato/flightplans/armedrecon.py b/game/ato/flightplans/armedrecon.py new file mode 100644 index 00000000..4ccc0439 --- /dev/null +++ b/game/ato/flightplans/armedrecon.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from typing import Type + +from .formationattack import ( + FormationAttackBuilder, + FormationAttackFlightPlan, + FormationAttackLayout, +) +from .uizonedisplay import UiZone, UiZoneDisplay +from ..flightwaypointtype import FlightWaypointType +from ...utils import nautical_miles + + +class ArmedReconFlightPlan(FormationAttackFlightPlan, UiZoneDisplay): + @staticmethod + def builder_type() -> Type[Builder]: + return Builder + + def ui_zone(self) -> UiZone: + return UiZone( + [self.tot_waypoint.position], + nautical_miles( + self.flight.coalition.game.settings.armed_recon_engagement_range_distance + ), + ) + + +class Builder(FormationAttackBuilder[ArmedReconFlightPlan, FormationAttackLayout]): + def layout(self) -> FormationAttackLayout: + return self._build(FlightWaypointType.INGRESS_ARMED_RECON) + + def build(self, dump_debug_info: bool = False) -> ArmedReconFlightPlan: + return ArmedReconFlightPlan(self.flight, self.layout()) diff --git a/game/ato/flightplans/cas.py b/game/ato/flightplans/cas.py index 26f7e7c6..6700cb52 100644 --- a/game/ato/flightplans/cas.py +++ b/game/ato/flightplans/cas.py @@ -125,16 +125,12 @@ class Builder(IBuilder[CasFlightPlan, CasLayout]): ingress_point_shapely.x, ingress_point_shapely.y ) - patrol_start_waypoint = builder.nav( - patrol_start, ingress_egress_altitude, use_agl_patrol_altitude - ) + patrol_start_waypoint = builder.cas(patrol_start, ingress_egress_altitude) patrol_start_waypoint.name = "FLOT START" patrol_start_waypoint.pretty_name = "FLOT start" patrol_start_waypoint.description = "FLOT boundary" - patrol_end_waypoint = builder.nav( - patrol_end, ingress_egress_altitude, use_agl_patrol_altitude - ) + patrol_end_waypoint = builder.cas(patrol_end, ingress_egress_altitude) patrol_end_waypoint.name = "FLOT END" patrol_end_waypoint.pretty_name = "FLOT end" patrol_end_waypoint.description = "FLOT boundary" diff --git a/game/ato/flightplans/flightplanbuildertypes.py b/game/ato/flightplans/flightplanbuildertypes.py index 5047e45c..9dd818f1 100644 --- a/game/ato/flightplans/flightplanbuildertypes.py +++ b/game/ato/flightplans/flightplanbuildertypes.py @@ -7,6 +7,7 @@ from .aewc import AewcFlightPlan from .airassault import AirAssaultFlightPlan from .airlift import AirliftFlightPlan from .antiship import AntiShipFlightPlan +from .armedrecon import ArmedReconFlightPlan from .bai import BaiFlightPlan from .barcap import BarCapFlightPlan from .cas import CasFlightPlan @@ -60,6 +61,7 @@ class FlightPlanBuilderTypes: FlightType.TRANSPORT: AirliftFlightPlan.builder_type(), FlightType.FERRY: FerryFlightPlan.builder_type(), FlightType.AIR_ASSAULT: AirAssaultFlightPlan.builder_type(), + FlightType.ARMED_RECON: ArmedReconFlightPlan.builder_type(), } try: return builder_dict[flight.flight_type] diff --git a/game/ato/flightplans/formationattack.py b/game/ato/flightplans/formationattack.py index c751a038..b4d87bca 100644 --- a/game/ato/flightplans/formationattack.py +++ b/game/ato/flightplans/formationattack.py @@ -286,6 +286,8 @@ class FormationAttackBuilder(IBuilder[FlightPlanT, LayoutT], ABC): return builder.sead_area(location) elif flight.flight_type == FlightType.OCA_AIRCRAFT: return builder.oca_strike_area(location) + elif flight.flight_type == FlightType.ARMED_RECON: + return builder.armed_recon_area(location) else: return builder.strike_area(location) diff --git a/game/ato/flightplans/waypointbuilder.py b/game/ato/flightplans/waypointbuilder.py index 74b1bc8d..03e25577 100644 --- a/game/ato/flightplans/waypointbuilder.py +++ b/game/ato/flightplans/waypointbuilder.py @@ -1,5 +1,6 @@ from __future__ import annotations +import math import random from dataclasses import dataclass from typing import ( @@ -252,18 +253,9 @@ class WaypointBuilder: if ingress_type in [ FlightWaypointType.INGRESS_CAS, FlightWaypointType.INGRESS_OCA_AIRCRAFT, + FlightWaypointType.INGRESS_ARMED_RECON, ]: - weather = self.flight.coalition.game.conditions.weather - max_alt = feet(30000) - if weather.clouds and ( - weather.clouds.preset - and "overcast" in weather.clouds.preset.description.lower() - or weather.clouds.density > 5 - ): - max_alt = meters( - max(feet(500).meters, weather.clouds.base - feet(500).meters) - ) - alt = min(alt, max_alt) + alt = self._adjust_altitude_for_clouds(alt) alt_type: AltitudeReference = "BARO" if self.is_helo or self.flight.is_hercules: @@ -291,6 +283,19 @@ class WaypointBuilder: targets=objective.strike_targets, ) + def _adjust_altitude_for_clouds(self, alt: Distance) -> Distance: + weather = self.flight.coalition.game.conditions.weather + max_alt = feet(math.inf) + if weather.clouds and ( + weather.clouds.preset + and "overcast" in weather.clouds.preset.description.lower() + or weather.clouds.density > 5 + ): + max_alt = meters( + max(feet(500).meters, weather.clouds.base - feet(500).meters) + ) + return min(alt, max_alt) + def egress(self, position: Point, target: MissionTarget) -> FlightWaypoint: alt_type: AltitudeReference = "BARO" if self.is_helo or self.get_combat_altitude.feet <= AGL_TRANSITION_ALT: @@ -354,6 +359,21 @@ class WaypointBuilder: def dead_area(self, target: MissionTarget) -> FlightWaypoint: return self._target_area(f"DEAD on {target.name}", target) + def armed_recon_area(self, target: MissionTarget) -> FlightWaypoint: + # Force AI aircraft to fly towards target area + alt = self.get_combat_altitude + alt = self._adjust_altitude_for_clouds(alt) + alt_type: AltitudeReference = "BARO" + if self.is_helo or alt.feet <= AGL_TRANSITION_ALT: + alt_type = "RADIO" + return self._target_area( + f"ARMED RECON {target.name}", + target, + altitude=alt, + alt_type=alt_type, + flyover=True, + ) + def oca_strike_area(self, target: MissionTarget) -> FlightWaypoint: return self._target_area(f"ATTACK {target.name}", target, flyover=True) @@ -398,15 +418,14 @@ class WaypointBuilder: waypoint.only_for_player = True return waypoint - def cas(self, position: Point) -> FlightWaypoint: + def cas(self, position: Point, altitude: Distance) -> FlightWaypoint: weather = self.flight.coalition.game.conditions.weather - max_alt = feet(30000) if weather.clouds and ( weather.clouds.preset and "overcast" in weather.clouds.preset.description.lower() or weather.clouds.density > 5 ): - max_alt = meters( + altitude = meters( max(feet(500).meters, weather.clouds.base - feet(500).meters) ) return FlightWaypoint( @@ -415,7 +434,7 @@ class WaypointBuilder: position, feet(self.flight.coalition.game.settings.heli_combat_alt_agl) if self.is_helo - else min(meters(1000), max_alt), + else max(meters(1000), altitude), "RADIO", description="Provide CAS", pretty_name="CAS", @@ -667,14 +686,13 @@ class WaypointBuilder: This waypoint is used to generate the Trigger Zone used for AirAssault and AirLift using the CTLD plugin (see LogisticsGenerator) """ - heli_alt = feet(self.flight.coalition.game.settings.heli_cruise_alt_agl) - altitude = heli_alt if self.flight.is_helo else meters(0) + alt = self.get_combat_altitude if self.flight.is_helo else meters(0) return FlightWaypoint( "DROPOFFZONE", FlightWaypointType.DROPOFF_ZONE, drop_off.position, - altitude, + alt, "RADIO", description=f"Drop off cargo at {drop_off.name}", pretty_name="Drop-off zone", diff --git a/game/ato/flighttype.py b/game/ato/flighttype.py index 615aaa5b..c7b7e5e3 100644 --- a/game/ato/flighttype.py +++ b/game/ato/flighttype.py @@ -58,6 +58,7 @@ class FlightType(Enum): FERRY = "Ferry" AIR_ASSAULT = "Air Assault" SEAD_SWEEP = "SEAD Sweep" # Reintroduce legacy "engage-whatever-you-can-find" SEAD + ARMED_RECON = "Armed Recon" def __str__(self) -> str: return self.value @@ -93,6 +94,7 @@ class FlightType(Enum): FlightType.SEAD_ESCORT, FlightType.AIR_ASSAULT, FlightType.SEAD_SWEEP, + FlightType.ARMED_RECON, } @property @@ -104,6 +106,7 @@ class FlightType(Enum): return { FlightType.AEWC: AirEntity.AIRBORNE_EARLY_WARNING, FlightType.ANTISHIP: AirEntity.ANTISURFACE_WARFARE, + FlightType.ARMED_RECON: AirEntity.ATTACK_STRIKE, FlightType.BAI: AirEntity.ATTACK_STRIKE, FlightType.BARCAP: AirEntity.FIGHTER, FlightType.CAS: AirEntity.ATTACK_STRIKE, diff --git a/game/ato/flightwaypointtype.py b/game/ato/flightwaypointtype.py index 8ba5024c..6a6c691b 100644 --- a/game/ato/flightwaypointtype.py +++ b/game/ato/flightwaypointtype.py @@ -51,3 +51,4 @@ class FlightWaypointType(IntEnum): INGRESS_AIR_ASSAULT = 31 INGRESS_ANTI_SHIP = 32 INGRESS_SEAD_SWEEP = 33 + INGRESS_ARMED_RECON = 34 diff --git a/game/ato/loadouts.py b/game/ato/loadouts.py index 2b3ded30..8b3f692a 100644 --- a/game/ato/loadouts.py +++ b/game/ato/loadouts.py @@ -208,6 +208,7 @@ class Loadout: loadout_names[FlightType.INTERCEPTION].extend(loadout_names[FlightType.BARCAP]) # OCA/Aircraft falls back to BAI, which falls back to CAS. loadout_names[FlightType.BAI].extend(loadout_names[FlightType.CAS]) + loadout_names[FlightType.ARMED_RECON].extend(loadout_names[FlightType.CAS]) loadout_names[FlightType.OCA_AIRCRAFT].extend(loadout_names[FlightType.BAI]) # DEAD also falls back to BAI. loadout_names[FlightType.DEAD].extend(loadout_names[FlightType.BAI]) diff --git a/game/ato/package.py b/game/ato/package.py index 387b03ed..84db40e2 100644 --- a/game/ato/package.py +++ b/game/ato/package.py @@ -183,6 +183,7 @@ class Package(RadioFrequencyContainer): FlightType.SEAD_SWEEP, FlightType.TARCAP, FlightType.BARCAP, + FlightType.ARMED_RECON, FlightType.AEWC, FlightType.FERRY, FlightType.REFUELING, diff --git a/game/commander/objectivefinder.py b/game/commander/objectivefinder.py index c789392e..020398a3 100644 --- a/game/commander/objectivefinder.py +++ b/game/commander/objectivefinder.py @@ -167,24 +167,6 @@ class ObjectiveFinder: yield cp break - def vulnerable_enemy_control_points(self) -> Iterator[ControlPoint]: - """Iterates over enemy CPs that are vulnerable to Air Assault. - Vulnerability is defined as any unit being alive in the CP's "blocking_capture" groups. - """ - for cp in self.enemy_control_points(): - include = True - for tgo in cp.connected_objectives: - if tgo.distance_to(cp) > cp.CAPTURE_DISTANCE.meters: - continue - for u in tgo.units: - if u.is_vehicle and u.alive: - include = False - break - if not include: - break - if include: - yield cp - def oca_targets(self, min_aircraft: int) -> Iterator[ControlPoint]: parking_type = ParkingType() parking_type.include_rotary_wing = True diff --git a/game/commander/theaterstate.py b/game/commander/theaterstate.py index 36cc6262..f84735a9 100644 --- a/game/commander/theaterstate.py +++ b/game/commander/theaterstate.py @@ -6,7 +6,7 @@ import math from collections.abc import Iterator from dataclasses import dataclass from datetime import datetime -from typing import Optional, TYPE_CHECKING, Union +from typing import Optional, TYPE_CHECKING, Union, Dict from game.commander.battlepositions import BattlePositions from game.commander.objectivefinder import ObjectiveFinder @@ -163,6 +163,15 @@ class TheaterState(WorldState["TheaterState"]): barcap_duration = coalition.doctrine.cap_duration.total_seconds() barcap_rounds = math.ceil(mission_duration / barcap_duration) + battle_postitions: Dict[ControlPoint, BattlePositions] = { + cp: BattlePositions.for_control_point(cp) + for cp in ordered_capturable_points + } + + vulnerable_control_points = [ + cp for cp, bp in battle_postitions.items() if not bp.blocking_capture + ] + return TheaterState( context=context, barcaps_needed={ @@ -179,10 +188,7 @@ class TheaterState(WorldState["TheaterState"]): enemy_convoys=list(finder.convoys()), enemy_shipping=list(finder.cargo_ships()), enemy_ships=list(finder.enemy_ships()), - enemy_battle_positions={ - cp: BattlePositions.for_control_point(cp) - for cp in ordered_capturable_points - }, + enemy_battle_positions=battle_postitions, oca_targets=list( finder.oca_targets( min_aircraft=game.settings.oca_target_autoplanner_min_aircraft_count @@ -191,5 +197,5 @@ class TheaterState(WorldState["TheaterState"]): strike_targets=list(finder.strike_targets()), enemy_barcaps=list(game.theater.control_points_for(not player)), threat_zones=game.threat_zone_for(not player), - vulnerable_control_points=list(finder.vulnerable_enemy_control_points()), + vulnerable_control_points=vulnerable_control_points, ) diff --git a/game/dcs/aircrafttype.py b/game/dcs/aircrafttype.py index b26198fb..f14c3c7b 100644 --- a/game/dcs/aircrafttype.py +++ b/game/dcs/aircrafttype.py @@ -241,9 +241,18 @@ class AircraftType(UnitType[Type[FlyingType]]): def __post_init__(self) -> None: enrich = {} - for t in self.task_priorities: - if t == FlightType.SEAD: - enrich[FlightType.SEAD_SWEEP] = self.task_priorities[t] + if FlightType.SEAD_SWEEP not in self.task_priorities: + if (value := self.task_priorities.get(FlightType.SEAD)) or ( + value := self.task_priorities.get(FlightType.SEAD_ESCORT) + ): + enrich[FlightType.SEAD_SWEEP] = value + + if FlightType.ARMED_RECON not in self.task_priorities: + if (value := self.task_priorities.get(FlightType.CAS)) or ( + value := self.task_priorities.get(FlightType.BAI) + ): + enrich[FlightType.ARMED_RECON] = value + self.task_priorities.update(enrich) @classmethod @@ -526,17 +535,7 @@ class AircraftType(UnitType[Type[FlyingType]]): if prop_overrides is not None: cls._set_props_overrides(prop_overrides, aircraft) - from game.ato.flighttype import FlightType - - task_priorities: dict[FlightType, int] = {} - for task_name, priority in data.get("tasks", {}).items(): - task_priorities[FlightType(task_name)] = priority - - if ( - FlightType.SEAD_SWEEP not in task_priorities - and FlightType.SEAD in task_priorities - ): - task_priorities[FlightType.SEAD_SWEEP] = task_priorities[FlightType.SEAD] + task_priorities = cls.get_task_priorities(data) cls._custom_weapon_injections(aircraft, data) cls._user_weapon_injections(aircraft) @@ -583,6 +582,27 @@ class AircraftType(UnitType[Type[FlyingType]]): use_f15e_waypoint_names=data.get("use_f15e_waypoint_names", False), ) + @classmethod + def get_task_priorities(cls, data: dict[str, Any]) -> dict[FlightType, int]: + task_priorities: dict[FlightType, int] = {} + for task_name, priority in data.get("tasks", {}).items(): + task_priorities[FlightType(task_name)] = priority + if ( + FlightType.SEAD_SWEEP not in task_priorities + and FlightType.SEAD in task_priorities + ): + task_priorities[FlightType.SEAD_SWEEP] = task_priorities[FlightType.SEAD] + if FlightType.ARMED_RECON not in task_priorities: + if FlightType.CAS in task_priorities: + task_priorities[FlightType.ARMED_RECON] = task_priorities[ + FlightType.CAS + ] + elif FlightType.BAI in task_priorities: + task_priorities[FlightType.ARMED_RECON] = task_priorities[ + FlightType.BAI + ] + return task_priorities + @staticmethod def _custom_weapon_injections( aircraft: Type[FlyingType], data: Dict[str, Any] diff --git a/game/missiongenerator/aircraft/aircraftbehavior.py b/game/missiongenerator/aircraft/aircraftbehavior.py index e458305e..45535774 100644 --- a/game/missiongenerator/aircraft/aircraftbehavior.py +++ b/game/missiongenerator/aircraft/aircraftbehavior.py @@ -57,6 +57,8 @@ class AircraftBehavior: self.configure_refueling(group, flight) elif self.task in [FlightType.CAS, FlightType.BAI]: self.configure_cas(group, flight) + elif self.task == FlightType.ARMED_RECON: + self.configure_armed_recon(group, flight) elif self.task == FlightType.DEAD: self.configure_dead(group, flight) elif self.task in [FlightType.SEAD, FlightType.SEAD_SWEEP]: @@ -183,6 +185,17 @@ class AircraftBehavior: restrict_jettison=True, ) + def configure_armed_recon(self, group: FlyingGroup[Any], flight: Flight) -> None: + self.configure_task(flight, group, CAS, [AFAC, AntishipStrike]) + self.configure_behavior( + flight, + group, + react_on_threat=OptReactOnThreat.Values.EvadeFire, + roe=OptROE.Values.OpenFire, + rtb_winchester=OptRTBOnOutOfAmmo.Values.All, + restrict_jettison=True, + ) + def configure_dead(self, group: FlyingGroup[Any], flight: Flight) -> None: # Only CAS and SEAD are capable of the Attack Group task. SEAD is arguably more # appropriate but it has an extremely limited list of capable aircraft, whereas diff --git a/game/missiongenerator/aircraft/waypoints/armedreconingress.py b/game/missiongenerator/aircraft/waypoints/armedreconingress.py new file mode 100644 index 00000000..094517dc --- /dev/null +++ b/game/missiongenerator/aircraft/waypoints/armedreconingress.py @@ -0,0 +1,35 @@ +from dcs.point import MovingPoint +from dcs.task import ( + OptECMUsing, + ControlledTask, + Targets, + EngageTargetsInZone, +) + +from game.utils import nautical_miles +from .pydcswaypointbuilder import PydcsWaypointBuilder + + +class ArmedReconIngressBuilder(PydcsWaypointBuilder): + def add_tasks(self, waypoint: MovingPoint) -> None: + self.register_special_ingress_points() + # Preemptively use ECM to better avoid getting swatted. + ecm_option = OptECMUsing(value=OptECMUsing.Values.UseIfDetectedLockByRadar) + waypoint.tasks.append(ecm_option) + + waypoint.add_task( + ControlledTask( + EngageTargetsInZone( + position=self.flight.flight_plan.tot_waypoint.position, + radius=int( + nautical_miles( + self.flight.coalition.game.settings.armed_recon_engagement_range_distance + ).meters + ), + targets=[ + Targets.All.GroundUnits, + Targets.All.Air.Helicopters, + ], + ) + ) + ) diff --git a/game/missiongenerator/aircraft/waypoints/waypointgenerator.py b/game/missiongenerator/aircraft/waypoints/waypointgenerator.py index 6974d55e..918545e1 100644 --- a/game/missiongenerator/aircraft/waypoints/waypointgenerator.py +++ b/game/missiongenerator/aircraft/waypoints/waypointgenerator.py @@ -22,6 +22,7 @@ from game.settings import Settings from game.utils import pairwise from .airassaultingress import AirAssaultIngressBuilder from .antishipingress import AntiShipIngressBuilder +from .armedreconingress import ArmedReconIngressBuilder from .baiingress import BaiIngressBuilder from .casingress import CasIngressBuilder from .deadingress import DeadIngressBuilder @@ -136,6 +137,7 @@ class WaypointGenerator: FlightWaypointType.DROPOFF_ZONE: LandingZoneBuilder, FlightWaypointType.INGRESS_AIR_ASSAULT: AirAssaultIngressBuilder, FlightWaypointType.INGRESS_ANTI_SHIP: AntiShipIngressBuilder, + FlightWaypointType.INGRESS_ARMED_RECON: ArmedReconIngressBuilder, FlightWaypointType.INGRESS_BAI: BaiIngressBuilder, FlightWaypointType.INGRESS_CAS: CasIngressBuilder, FlightWaypointType.INGRESS_DEAD: DeadIngressBuilder, diff --git a/game/settings/settings.py b/game/settings/settings.py index 74a88777..9db6d082 100644 --- a/game/settings/settings.py +++ b/game/settings/settings.py @@ -329,6 +329,14 @@ class Settings: min=0, max=100, ) + armed_recon_engagement_range_distance: int = bounded_int_option( + "Armed Recon engagement range (NM)", + page=CAMPAIGN_DOCTRINE_PAGE, + section=DOCTRINE_DISTANCES_SECTION, + default=5, + min=0, + max=25, + ) sead_sweep_engagement_range_distance: int = bounded_int_option( "SEAD Sweep engagement range (NM)", page=CAMPAIGN_DOCTRINE_PAGE, diff --git a/game/theater/missiontarget.py b/game/theater/missiontarget.py index 6350a6fb..4637aa44 100644 --- a/game/theater/missiontarget.py +++ b/game/theater/missiontarget.py @@ -39,6 +39,7 @@ class MissionTarget: FlightType.TARCAP, FlightType.SEAD_ESCORT, FlightType.SEAD_SWEEP, + FlightType.ARMED_RECON, FlightType.SWEEP, # TODO: FlightType.ELINT, # TODO: FlightType.EWAR, diff --git a/qt_ui/windows/mission/QAutoCreateDialog.py b/qt_ui/windows/mission/QAutoCreateDialog.py index 3792c6c4..f61e72c9 100644 --- a/qt_ui/windows/mission/QAutoCreateDialog.py +++ b/qt_ui/windows/mission/QAutoCreateDialog.py @@ -161,6 +161,7 @@ class QAutoCreateDialog(QDialog): FlightType.ANTISHIP, FlightType.BAI, FlightType.CAS, + FlightType.ARMED_RECON, } for mt in self.package.target.mission_types(self.is_ownfor): if mt in primary_tasks: