diff --git a/changelog.md b/changelog.md index b3368e40..4e469f6c 100644 --- a/changelog.md +++ b/changelog.md @@ -41,6 +41,7 @@ * **[Mission Generation]** Enable Supercarrier's LSO & Airboss stations * **[UX]** Default settings are now loaded from Default.zip * **[Autoplanner]** Plan Air-to-Air Escorts for AWACS & Tankers +* **[Package Planning]** Ability to plan recovery tanker flights ## Fixes * **[UI/UX]** A-10A flights can be edited again diff --git a/game/ato/flightplans/custom.py b/game/ato/flightplans/custom.py index a0191ea8..3b4da67b 100644 --- a/game/ato/flightplans/custom.py +++ b/game/ato/flightplans/custom.py @@ -60,6 +60,16 @@ class CustomFlightPlan(FlightPlan[CustomLayout]): def mission_departure_time(self) -> datetime: return self.package.time_over_target + @property + def landing_time(self) -> datetime: + arrival = ( + self.layout.custom_waypoints[-1] + if self.layout.custom_waypoints + else self.layout.departure + ) + return_time = self.total_time_between_waypoints(self.tot_waypoint, arrival) + return self.tot + return_time + class Builder(IBuilder[CustomFlightPlan, CustomLayout]): def __init__( diff --git a/game/ato/flightplans/flightplan.py b/game/ato/flightplans/flightplan.py index 8cf1cbe8..049c60c7 100644 --- a/game/ato/flightplans/flightplan.py +++ b/game/ato/flightplans/flightplan.py @@ -296,6 +296,10 @@ class FlightPlan(ABC, Generic[LayoutT]): """The time that the mission is complete and the flight RTBs.""" raise NotImplementedError + @property + def landing_time(self) -> datetime: + raise NotImplementedError + @self_type_guard def is_loiter(self, flight_plan: FlightPlan[Any]) -> TypeGuard[LoiterFlightPlan]: return False diff --git a/game/ato/flightplans/flightplanbuildertypes.py b/game/ato/flightplans/flightplanbuildertypes.py index 5ec691d2..a83dbda5 100644 --- a/game/ato/flightplans/flightplanbuildertypes.py +++ b/game/ato/flightplans/flightplanbuildertypes.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import Any, TYPE_CHECKING, Type from game.ato import FlightType +from game.theater.controlpoint import NavalControlPoint from .aewc import AewcFlightPlan from .airassault import AirAssaultFlightPlan from .airlift import AirliftFlightPlan @@ -22,6 +23,7 @@ from .planningerror import PlanningError from .pretensecargo import PretenseCargoFlightPlan from .sead import SeadFlightPlan from .seadsweep import SeadSweepFlightPlan +from .shiprecoverytanker import RecoveryTankerFlightPlan from .strike import StrikeFlightPlan from .sweep import SweepFlightPlan from .tarcap import TarCapFlightPlan @@ -64,6 +66,7 @@ class FlightPlanBuilderTypes: FlightType.AIR_ASSAULT: AirAssaultFlightPlan.builder_type(), FlightType.PRETENSE_CARGO: PretenseCargoFlightPlan.builder_type(), FlightType.ARMED_RECON: ArmedReconFlightPlan.builder_type(), + FlightType.RECOVERY: RecoveryTankerFlightPlan.builder_type(), } try: return builder_dict[flight.flight_type] diff --git a/game/ato/flightplans/shiprecoverytanker.py b/game/ato/flightplans/shiprecoverytanker.py new file mode 100644 index 00000000..85cb2f4d --- /dev/null +++ b/game/ato/flightplans/shiprecoverytanker.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from datetime import timedelta +from typing import Type + +from game.ato.flightplans.ibuilder import IBuilder +from game.ato.flightplans.waypointbuilder import WaypointBuilder +from .patrolling import PatrollingLayout +from .refuelingflightplan import RefuelingFlightPlan +from .. import FlightWaypoint +from ...utils import knots + + +class RecoveryTankerFlightPlan(RefuelingFlightPlan): + @staticmethod + def builder_type() -> Type[Builder]: + return Builder + + @property + def patrol_duration(self) -> timedelta: + return self.flight.coalition.game.settings.desired_tanker_on_station_time + + @property + def tot_waypoint(self) -> FlightWaypoint: + return self.layout.departure + + +class Builder(IBuilder[RecoveryTankerFlightPlan, PatrollingLayout]): + def layout(self) -> PatrollingLayout: + + builder = WaypointBuilder(self.flight) + altitude = builder.get_patrol_altitude + + station_time = self.coalition.game.settings.desired_tanker_on_station_time + time_to_landing = station_time.total_seconds() + hdg = (self.coalition.game.conditions.weather.wind.at_0m.direction + 180) % 360 + recovery_ship = self.package.target.position.point_from_heading( + hdg, time_to_landing * knots(20).meters_per_second + ) + recovery_tanker = builder.recovery_tanker(recovery_ship) + patrol_end = builder.race_track_end(recovery_tanker.position, altitude) + patrol_end.only_for_player = True # avoid generating the waypoints + + return PatrollingLayout( + departure=builder.takeoff(self.flight.departure), + nav_to=builder.nav_path( + self.flight.departure.position, recovery_ship, altitude + ), + nav_from=builder.nav_path( + recovery_ship, self.flight.arrival.position, altitude + ), + patrol_start=recovery_tanker, + patrol_end=patrol_end, + arrival=builder.land(self.flight.arrival), + divert=builder.divert(self.flight.divert), + bullseye=builder.bullseye(), + custom_waypoints=list(), + ) + + def build(self, dump_debug_info: bool = False) -> RecoveryTankerFlightPlan: + return RecoveryTankerFlightPlan(self.flight, self.layout()) diff --git a/game/ato/flightplans/standard.py b/game/ato/flightplans/standard.py index 1235fd60..8d97aab9 100644 --- a/game/ato/flightplans/standard.py +++ b/game/ato/flightplans/standard.py @@ -3,6 +3,7 @@ from __future__ import annotations from abc import ABC from copy import deepcopy from dataclasses import dataclass +from datetime import datetime from typing import TYPE_CHECKING, TypeVar, Optional from game.ato.flightplans.flightplan import FlightPlan, Layout @@ -86,3 +87,10 @@ class StandardFlightPlan(FlightPlan[LayoutT], ABC): others are guaranteed to have certain properties like departure and arrival points, potentially a divert field, and a bullseye """ + + @property + def landing_time(self) -> datetime: + return_time = self.total_time_between_waypoints( + self.tot_waypoint, self.layout.arrival + ) + return self.tot + return_time diff --git a/game/ato/flightplans/waypointbuilder.py b/game/ato/flightplans/waypointbuilder.py index fbaf503d..c2fa4e78 100644 --- a/game/ato/flightplans/waypointbuilder.py +++ b/game/ato/flightplans/waypointbuilder.py @@ -800,3 +800,18 @@ class WaypointBuilder: x_adj = random.randint(int(-deviation.meters), int(deviation.meters)) y_adj = random.randint(int(-deviation.meters), int(deviation.meters)) return point + Vector2(x_adj, y_adj) + + @staticmethod + def recovery_tanker(position: Point) -> FlightWaypoint: + alt_type: AltitudeReference = "BARO" + + return FlightWaypoint( + "RECOVERY", + FlightWaypointType.RECOVERY_TANKER, + position, + feet(6000), + alt_type, + description="Recovery tanker for aircraft carriers", + pretty_name="Recovery", + only_for_player=True, # for visual purposes in Retribution only + ) diff --git a/game/ato/flighttype.py b/game/ato/flighttype.py index 7b210e77..062733b6 100644 --- a/game/ato/flighttype.py +++ b/game/ato/flighttype.py @@ -60,6 +60,7 @@ class FlightType(Enum): SEAD_SWEEP = "SEAD Sweep" # Reintroduce legacy "engage-whatever-you-can-find" SEAD PRETENSE_CARGO = "Cargo Transport" # For Pretense campaign AI cargo planes ARMED_RECON = "Armed Recon" + RECOVERY = "Recovery" def __str__(self) -> str: return self.value @@ -117,6 +118,7 @@ class FlightType(Enum): FlightType.INTERCEPTION: AirEntity.FIGHTER, FlightType.OCA_AIRCRAFT: AirEntity.ATTACK_STRIKE, FlightType.OCA_RUNWAY: AirEntity.ATTACK_STRIKE, + FlightType.RECOVERY: AirEntity.TANKER, FlightType.REFUELING: AirEntity.TANKER, FlightType.SEAD: AirEntity.SUPPRESSION_OF_ENEMY_AIR_DEFENCE, FlightType.SEAD_ESCORT: AirEntity.SUPPRESSION_OF_ENEMY_AIR_DEFENCE, diff --git a/game/ato/flightwaypointtype.py b/game/ato/flightwaypointtype.py index 6a6c691b..0abf0fd9 100644 --- a/game/ato/flightwaypointtype.py +++ b/game/ato/flightwaypointtype.py @@ -52,3 +52,4 @@ class FlightWaypointType(IntEnum): INGRESS_ANTI_SHIP = 32 INGRESS_SEAD_SWEEP = 33 INGRESS_ARMED_RECON = 34 + RECOVERY_TANKER = 35 # Tanker recovery point diff --git a/game/ato/package.py b/game/ato/package.py index 5095b441..dcffa306 100644 --- a/game/ato/package.py +++ b/game/ato/package.py @@ -188,6 +188,7 @@ class Package(RadioFrequencyContainer): FlightType.BARCAP, FlightType.AEWC, FlightType.FERRY, + FlightType.RECOVERY, FlightType.REFUELING, FlightType.SWEEP, FlightType.SEAD_ESCORT, diff --git a/game/commander/missionscheduler.py b/game/commander/missionscheduler.py index fe308219..608ea2fe 100644 --- a/game/commander/missionscheduler.py +++ b/game/commander/missionscheduler.py @@ -40,6 +40,8 @@ class MissionScheduler: p for p in self.coalition.ato.packages if p.primary_task not in dca_types ] + carrier_etas = [] + start_time = start_time_generator( count=len(non_dca_packages), earliest=5 * 60, @@ -47,6 +49,8 @@ class MissionScheduler: margin=5 * 60, ) for package in self.coalition.ato.packages: + if package.primary_task is FlightType.RECOVERY: + continue tot = TotEstimator(package).earliest_tot(now) if package.primary_task in dca_types: previous_end_time = previous_cap_end_time[package.target] @@ -74,3 +78,19 @@ class MissionScheduler: # to be present. Runway and air started aircraft will be # delayed until their takeoff time by AirConflictGenerator. package.time_over_target = next(start_time) + tot + arrivals = [] + for f in package.flights: + if f.departure.is_fleet and not f.is_helo: + arrivals.append(f.flight_plan.landing_time - timedelta(minutes=10)) + if arrivals: + carrier_etas.append(min(arrivals)) + + for package in [ + p + for p in self.coalition.ato.packages + if p.primary_task is FlightType.RECOVERY + ]: + if carrier_etas: + package.time_over_target = carrier_etas.pop(0) + else: + break diff --git a/game/commander/objectivefinder.py b/game/commander/objectivefinder.py index 4806c85e..bc2e17ec 100644 --- a/game/commander/objectivefinder.py +++ b/game/commander/objectivefinder.py @@ -246,6 +246,9 @@ class ObjectiveFinder: raise RuntimeError("Found no friendly control points. You probably lost.") return closest + def friendly_naval_control_points(self) -> Iterator[ControlPoint]: + return (cp for cp in self.friendly_control_points() if cp.is_fleet) + def enemy_control_points(self) -> Iterator[ControlPoint]: """Iterates over all enemy control points.""" return ( diff --git a/game/commander/packagebuilder.py b/game/commander/packagebuilder.py index 05fd0386..5fbb8b96 100644 --- a/game/commander/packagebuilder.py +++ b/game/commander/packagebuilder.py @@ -53,7 +53,8 @@ class PackageBuilder: if pf: target = ( pf.departure - if pf.flight_type in [FlightType.AEWC, FlightType.REFUELING] + if pf.flight_type + in [FlightType.AEWC, FlightType.REFUELING, FlightType.RECOVERY] else target ) heli = pf.is_helo diff --git a/game/commander/tasks/compound/nextaction.py b/game/commander/tasks/compound/nextaction.py index f2c50c7a..e3423e18 100644 --- a/game/commander/tasks/compound/nextaction.py +++ b/game/commander/tasks/compound/nextaction.py @@ -14,6 +14,7 @@ from game.commander.tasks.compound.interdictreinforcements import ( InterdictReinforcements, ) from game.commander.tasks.compound.protectairspace import ProtectAirSpace +from game.commander.tasks.compound.recoverysupport import RecoverySupport from game.commander.tasks.compound.theatersupport import TheaterSupport from game.commander.theaterstate import TheaterState from game.htn import CompoundTask, Method @@ -34,3 +35,4 @@ class PlanNextAction(CompoundTask[TheaterState]): yield [AttackBuildings()] yield [AttackShips()] yield [DegradeIads()] + yield [RecoverySupport()] # for recovery tankers diff --git a/game/commander/tasks/compound/recoverysupport.py b/game/commander/tasks/compound/recoverysupport.py new file mode 100644 index 00000000..6300fd81 --- /dev/null +++ b/game/commander/tasks/compound/recoverysupport.py @@ -0,0 +1,16 @@ +from collections.abc import Iterator + +from game.commander.tasks.primitive.recovery import PlanRecovery +from game.commander.theaterstate import TheaterState +from game.htn import CompoundTask, Method + + +class RecoverySupport(CompoundTask[TheaterState]): + def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]: + yield [PlanRecoverySupport()] + + +class PlanRecoverySupport(CompoundTask[TheaterState]): + def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]: + for target in state.recovery_targets: + yield [PlanRecovery(target)] diff --git a/game/commander/tasks/packageplanningtask.py b/game/commander/tasks/packageplanningtask.py index f7a8c2bb..e929ca19 100644 --- a/game/commander/tasks/packageplanningtask.py +++ b/game/commander/tasks/packageplanningtask.py @@ -16,7 +16,7 @@ from game.commander.tasks.theatercommandertask import TheaterCommanderTask from game.commander.theaterstate import TheaterState from game.data.groups import GroupTask from game.settings import AutoAtoBehavior -from game.theater import MissionTarget +from game.theater import MissionTarget, ControlPoint from game.theater.theatergroundobject import IadsGroundObject, NavalGroundObject from game.utils import Distance, meters @@ -51,6 +51,15 @@ class PackagePlanningTask(TheaterCommanderTask, Generic[MissionTargetT]): return False return self.fulfill_mission(state) + def apply_effects(self, state: TheaterState) -> None: + seen: set[ControlPoint] = set() + if not self.package: + return + for f in self.package.flights: + if f.departure.is_fleet and not f.is_helo and f.departure not in seen: + state.recovery_targets[f.departure] += f.count + seen.add(f.departure) + def execute(self, coalition: Coalition) -> None: if self.package is None: raise RuntimeError("Attempted to execute failed package planning task") diff --git a/game/commander/tasks/primitive/aewc.py b/game/commander/tasks/primitive/aewc.py index b7c116e4..d19f6dfc 100644 --- a/game/commander/tasks/primitive/aewc.py +++ b/game/commander/tasks/primitive/aewc.py @@ -22,6 +22,7 @@ class PlanAewc(PackagePlanningTask[MissionTarget]): def apply_effects(self, state: TheaterState) -> None: state.aewc_targets.remove(self.target) + super().apply_effects(state) def propose_flights(self) -> None: self.propose_flight(FlightType.AEWC, 1) diff --git a/game/commander/tasks/primitive/airassault.py b/game/commander/tasks/primitive/airassault.py index 27fad824..b8c5144b 100644 --- a/game/commander/tasks/primitive/airassault.py +++ b/game/commander/tasks/primitive/airassault.py @@ -19,6 +19,7 @@ class PlanAirAssault(PackagePlanningTask[ControlPoint]): def apply_effects(self, state: TheaterState) -> None: state.vulnerable_control_points.remove(self.target) + super().apply_effects(state) def propose_flights(self) -> None: self.propose_flight(FlightType.AIR_ASSAULT, self.get_flight_size()) diff --git a/game/commander/tasks/primitive/antiship.py b/game/commander/tasks/primitive/antiship.py index 95a2c752..09b81851 100644 --- a/game/commander/tasks/primitive/antiship.py +++ b/game/commander/tasks/primitive/antiship.py @@ -1,7 +1,6 @@ from __future__ import annotations from dataclasses import dataclass -from random import randint from game.ato.flighttype import FlightType from game.commander.missionproposals import EscortType @@ -21,6 +20,7 @@ class PlanAntiShip(PackagePlanningTask[NavalGroundObject]): def apply_effects(self, state: TheaterState) -> None: state.eliminate_ship(self.target) + super().apply_effects(state) def propose_flights(self) -> None: size = self.get_flight_size() diff --git a/game/commander/tasks/primitive/antishipping.py b/game/commander/tasks/primitive/antishipping.py index 2843c24f..937820e5 100644 --- a/game/commander/tasks/primitive/antishipping.py +++ b/game/commander/tasks/primitive/antishipping.py @@ -20,6 +20,7 @@ class PlanAntiShipping(PackagePlanningTask[CargoShip]): def apply_effects(self, state: TheaterState) -> None: state.enemy_shipping.remove(self.target) + super().apply_effects(state) def propose_flights(self) -> None: size = self.get_flight_size() diff --git a/game/commander/tasks/primitive/armedrecon.py b/game/commander/tasks/primitive/armedrecon.py index 026a17e4..fabef44e 100644 --- a/game/commander/tasks/primitive/armedrecon.py +++ b/game/commander/tasks/primitive/armedrecon.py @@ -19,6 +19,7 @@ class PlanArmedRecon(PackagePlanningTask[ControlPoint]): def apply_effects(self, state: TheaterState) -> None: state.control_point_priority_queue.remove(self.target) + super().apply_effects(state) def propose_flights(self) -> None: self.propose_flight(FlightType.ARMED_RECON, self.get_flight_size()) diff --git a/game/commander/tasks/primitive/bai.py b/game/commander/tasks/primitive/bai.py index 1984247a..202b33af 100644 --- a/game/commander/tasks/primitive/bai.py +++ b/game/commander/tasks/primitive/bai.py @@ -19,6 +19,7 @@ class PlanBai(PackagePlanningTask[VehicleGroupGroundObject]): def apply_effects(self, state: TheaterState) -> None: state.eliminate_battle_position(self.target) + super().apply_effects(state) def propose_flights(self) -> None: tgt_count = self.target.alive_unit_count diff --git a/game/commander/tasks/primitive/barcap.py b/game/commander/tasks/primitive/barcap.py index 2ba674f9..b2a89de2 100644 --- a/game/commander/tasks/primitive/barcap.py +++ b/game/commander/tasks/primitive/barcap.py @@ -1,8 +1,6 @@ from __future__ import annotations -import random from dataclasses import dataclass -from random import randint from game.ato.flighttype import FlightType from game.commander.tasks.packageplanningtask import PackagePlanningTask @@ -21,6 +19,7 @@ class PlanBarcap(PackagePlanningTask[ControlPoint]): def apply_effects(self, state: TheaterState) -> None: state.barcaps_needed[self.target] -= 1 + super().apply_effects(state) def propose_flights(self) -> None: size = self.get_flight_size() diff --git a/game/commander/tasks/primitive/cas.py b/game/commander/tasks/primitive/cas.py index 43954fb7..c6fc47e5 100644 --- a/game/commander/tasks/primitive/cas.py +++ b/game/commander/tasks/primitive/cas.py @@ -28,6 +28,7 @@ class PlanCas(PackagePlanningTask[FrontLine]): def apply_effects(self, state: TheaterState) -> None: state.vulnerable_front_lines.remove(self.target) + super().apply_effects(state) def propose_flights(self) -> None: size = self.get_flight_size() diff --git a/game/commander/tasks/primitive/convoyinterdiction.py b/game/commander/tasks/primitive/convoyinterdiction.py index 98368056..f0327e3d 100644 --- a/game/commander/tasks/primitive/convoyinterdiction.py +++ b/game/commander/tasks/primitive/convoyinterdiction.py @@ -19,6 +19,7 @@ class PlanConvoyInterdiction(PackagePlanningTask[Convoy]): def apply_effects(self, state: TheaterState) -> None: state.enemy_convoys.remove(self.target) + super().apply_effects(state) def propose_flights(self) -> None: self.propose_flight(FlightType.BAI, 2) diff --git a/game/commander/tasks/primitive/dead.py b/game/commander/tasks/primitive/dead.py index ad7d26bb..40de4d5c 100644 --- a/game/commander/tasks/primitive/dead.py +++ b/game/commander/tasks/primitive/dead.py @@ -23,6 +23,7 @@ class PlanDead(PackagePlanningTask[IadsGroundObject]): def apply_effects(self, state: TheaterState) -> None: state.eliminate_air_defense(self.target) + super().apply_effects(state) def propose_flights(self) -> None: tgt_count = self.target.alive_unit_count diff --git a/game/commander/tasks/primitive/oca.py b/game/commander/tasks/primitive/oca.py index dd57c806..1cac168a 100644 --- a/game/commander/tasks/primitive/oca.py +++ b/game/commander/tasks/primitive/oca.py @@ -22,6 +22,7 @@ class PlanOcaStrike(PackagePlanningTask[ControlPoint]): def apply_effects(self, state: TheaterState) -> None: state.oca_targets.remove(self.target) + super().apply_effects(state) def propose_flights(self) -> None: size = self.get_flight_size() diff --git a/game/commander/tasks/primitive/recovery.py b/game/commander/tasks/primitive/recovery.py new file mode 100644 index 00000000..edb5e447 --- /dev/null +++ b/game/commander/tasks/primitive/recovery.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from game.ato.flighttype import FlightType +from game.commander.tasks.packageplanningtask import PackagePlanningTask +from game.commander.theaterstate import TheaterState +from game.theater import ControlPoint + + +@dataclass +class PlanRecovery(PackagePlanningTask[ControlPoint]): + def preconditions_met(self, state: TheaterState) -> bool: + if ( + state.context.coalition.player + and not state.context.settings.auto_ato_behavior_tankers + ): + return False + ac_per_tanker = state.context.settings.aircraft_per_recovery_tanker + if not ( + self.target in state.recovery_targets + and state.recovery_targets[self.target] >= ac_per_tanker + ): + return False + return super().preconditions_met(state) + + def apply_effects(self, state: TheaterState) -> None: + ac_per_tanker = state.context.settings.aircraft_per_recovery_tanker + state.recovery_targets[self.target] -= ac_per_tanker + + def propose_flights(self) -> None: + self.propose_flight(FlightType.RECOVERY, 1) diff --git a/game/commander/tasks/primitive/refueling.py b/game/commander/tasks/primitive/refueling.py index d10e6d0f..c863b46e 100644 --- a/game/commander/tasks/primitive/refueling.py +++ b/game/commander/tasks/primitive/refueling.py @@ -22,6 +22,7 @@ class PlanRefueling(PackagePlanningTask[MissionTarget]): def apply_effects(self, state: TheaterState) -> None: state.refueling_targets.remove(self.target) + super().apply_effects(state) def propose_flights(self) -> None: self.propose_flight(FlightType.REFUELING, 1) diff --git a/game/commander/tasks/primitive/strike.py b/game/commander/tasks/primitive/strike.py index a900810e..91b7e70f 100644 --- a/game/commander/tasks/primitive/strike.py +++ b/game/commander/tasks/primitive/strike.py @@ -20,6 +20,7 @@ class PlanStrike(PackagePlanningTask[TheaterGroundObject]): def apply_effects(self, state: TheaterState) -> None: state.strike_targets.remove(self.target) + super().apply_effects(state) def propose_flights(self) -> None: tgt_count = self.target.alive_unit_count diff --git a/game/commander/theaterstate.py b/game/commander/theaterstate.py index 4c49314d..312a715a 100644 --- a/game/commander/theaterstate.py +++ b/game/commander/theaterstate.py @@ -15,7 +15,12 @@ from game.ground_forces.combat_stance import CombatStance from game.htn import WorldState from game.profiling import MultiEventTracer from game.settings import Settings -from game.theater import ConflictTheater, ControlPoint, FrontLine, MissionTarget +from game.theater import ( + ConflictTheater, + ControlPoint, + FrontLine, + MissionTarget, +) from game.theater.theatergroundobject import ( BuildingGroundObject, IadsGroundObject, @@ -51,6 +56,7 @@ class TheaterState(WorldState["TheaterState"]): vulnerable_front_lines: list[FrontLine] aewc_targets: list[MissionTarget] refueling_targets: list[MissionTarget] + recovery_targets: dict[ControlPoint, int] enemy_air_defenses: list[IadsGroundObject] threatening_air_defenses: list[Union[IadsGroundObject, NavalGroundObject]] detecting_air_defenses: list[Union[IadsGroundObject, NavalGroundObject]] @@ -118,6 +124,7 @@ class TheaterState(WorldState["TheaterState"]): vulnerable_front_lines=list(self.vulnerable_front_lines), aewc_targets=list(self.aewc_targets), refueling_targets=list(self.refueling_targets), + recovery_targets=dict(self.recovery_targets), enemy_air_defenses=list(self.enemy_air_defenses), enemy_convoys=list(self.enemy_convoys), enemy_shipping=list(self.enemy_shipping), @@ -186,6 +193,7 @@ class TheaterState(WorldState["TheaterState"]): vulnerable_front_lines=list(finder.front_lines()), aewc_targets=[finder.farthest_friendly_control_point()], refueling_targets=[finder.closest_friendly_control_point()], + recovery_targets={cp: 0 for cp in finder.friendly_naval_control_points()}, enemy_air_defenses=list(finder.enemy_air_defenses()), threatening_air_defenses=[], detecting_air_defenses=[], diff --git a/game/dcs/aircrafttype.py b/game/dcs/aircrafttype.py index 9ae36de0..c56660f6 100644 --- a/game/dcs/aircrafttype.py +++ b/game/dcs/aircrafttype.py @@ -262,6 +262,12 @@ class AircraftType(UnitType[Type[FlyingType]]): ): enrich[FlightType.ARMED_RECON] = value + if FlightType.RECOVERY not in self.task_priorities: + if ( + value := self.task_priorities.get(FlightType.REFUELING) + ) and self.carrier_capable is True: + enrich[FlightType.RECOVERY] = value + self.task_priorities.update(enrich) def __eq__(self, other: object) -> bool: diff --git a/game/game.py b/game/game.py index 43598f40..33ebb046 100644 --- a/game/game.py +++ b/game/game.py @@ -554,6 +554,7 @@ class Game: FlightType.TRANSPORT, FlightType.AEWC, FlightType.REFUELING, + FlightType.RECOVERY, ]: # BARCAPs will be planned at most locations on smaller theaters, # rendering culling fairly useless. BARCAP packages don't really diff --git a/game/missiongenerator/aircraft/aircraftbehavior.py b/game/missiongenerator/aircraft/aircraftbehavior.py index 7ab55e4f..9cdf6933 100644 --- a/game/missiongenerator/aircraft/aircraftbehavior.py +++ b/game/missiongenerator/aircraft/aircraftbehavior.py @@ -30,22 +30,33 @@ from dcs.task import ( OptNoReportWaypointPass, OptRadioUsageContact, OptRadioSilence, + Tanker, + RecoveryTanker, + ActivateBeaconCommand, + ControlledTask, ) -from dcs.unitgroup import FlyingGroup +from dcs.unitgroup import FlyingGroup, ShipGroup -from game.ato import Flight, FlightType +from game.ato import Flight, FlightType, Package from game.ato.flightplans.aewc import AewcFlightPlan from game.ato.flightplans.formationattack import FormationAttackLayout from game.ato.flightplans.packagerefueling import PackageRefuelingFlightPlan +from game.ato.flightplans.shiprecoverytanker import RecoveryTankerFlightPlan from game.ato.flightplans.theaterrefueling import TheaterRefuelingFlightPlan -from game.utils import nautical_miles +from game.missiongenerator.missiondata import MissionData +from game.utils import nautical_miles, knots, feet class AircraftBehavior: - def __init__(self, task: FlightType) -> None: + def __init__(self, task: FlightType, mission_data: MissionData) -> None: self.task = task + self.mission_data = mission_data - def apply_to(self, flight: Flight, group: FlyingGroup[Any]) -> None: + def apply_to( + self, + flight: Flight, + group: FlyingGroup[Any], + ) -> None: if self.task in [ FlightType.BARCAP, FlightType.TARCAP, @@ -58,6 +69,8 @@ class AircraftBehavior: self.configure_awacs(group, flight) elif self.task == FlightType.REFUELING: self.configure_refueling(group, flight) + elif self.task == FlightType.RECOVERY: + self.configure_recovery(group, flight) elif self.task in [FlightType.CAS, FlightType.BAI]: self.configure_cas(group, flight) elif self.task == FlightType.ARMED_RECON: @@ -317,6 +330,7 @@ class AircraftBehavior: if not ( isinstance(flight.flight_plan, TheaterRefuelingFlightPlan) or isinstance(flight.flight_plan, PackageRefuelingFlightPlan) + or isinstance(flight.flight_plan, RecoveryTankerFlightPlan) ): logging.error( f"Cannot configure racetrack refueling tasks for {flight} because it " @@ -332,6 +346,95 @@ class AircraftBehavior: restrict_jettison=True, ) + def configure_recovery( + self, + group: FlyingGroup[Any], + flight: Flight, + ) -> None: + self.configure_refueling(group, flight) + if not isinstance(flight.flight_plan, RecoveryTankerFlightPlan): + logging.error( + f"Cannot configure recovery task for {flight} because it " + "does not have an recovery tanker flight plan." + ) + return + + self.configure_tanker_tacan(flight, group) + + clouds = flight.squadron.coalition.game.conditions.weather.clouds + speed = knots(250).meters_per_second + altitude = feet(6000).meters + if clouds is not None: + if abs(clouds.base - altitude) < feet(1000).meters: + altitude = clouds.base - feet(1000).meters + if altitude < feet(2000).meters: + altitude = clouds.base + feet(6000).meters + + naval_group = self._get_carrier_group(flight.package) + last_waypoint = len(naval_group.points) # last waypoint of the CVN/LHA + + tanker_tos = flight.coalition.game.settings.desired_tanker_on_station_time + lua_predicate = f""" + local lowfuel = false + for i, unitObject in pairs(Group.getByName('{group.name}'):getUnits()) do + if Unit.getFuel(unitObject) < 0.2 then lowfuel = true end + end + return lowfuel + """ + + tanker = ControlledTask(Tanker()) + tanker.stop_after_duration(int(tanker_tos.total_seconds()) + 1) + tanker.stop_if_lua_predicate(lua_predicate) + group.points[0].add_task(tanker) + + recovery = ControlledTask( + RecoveryTanker(naval_group.id, speed, altitude, last_waypoint) + ) + recovery.stop_if_lua_predicate(lua_predicate) + recovery.stop_after_duration(int(tanker_tos.total_seconds()) + 1) + group.points[0].add_task(recovery) + + def configure_tanker_tacan(self, flight: Flight, group: FlyingGroup[Any]) -> None: + tanker_info = self.mission_data.tankers[-1] + tacan = tanker_info.tacan + if flight.unit_type.dcs_unit_type.tacan and tacan: + if flight.tcn_name is None: + cs = tanker_info.callsign[:-2] + csn = tanker_info.callsign[-1] + tacan_callsign = { + "Texaco": "TX", + "Arco": "AC", + "Shell": "SH", + }.get(cs) + if tacan_callsign: + tacan_callsign = tacan_callsign + csn + else: + tacan_callsign = cs[0:2] + csn + else: + tacan_callsign = flight.tcn_name + + group.points[0].add_task( + ActivateBeaconCommand( + tacan.number, + tacan.band.value, + tacan_callsign.upper(), + bearing=True, + unit_id=group.units[0].id, + aa=True, + ) + ) + + def _get_carrier_group(self, package: Package) -> ShipGroup: + name = package.target.name + carrier_position = package.target.position + for carrier in self.mission_data.carriers: + if carrier.ship_group.position == carrier_position: + return carrier.ship_group + raise RuntimeError( + f"Could not find a carrier in the mission matching {name} at " + f"({carrier_position.x}, {carrier_position.y})" + ) + def configure_escort(self, group: FlyingGroup[Any], flight: Flight) -> None: self.configure_task(flight, group, Escort) self.configure_behavior( diff --git a/game/missiongenerator/aircraft/flightgroupconfigurator.py b/game/missiongenerator/aircraft/flightgroupconfigurator.py index d8c2a103..ea29a6f6 100644 --- a/game/missiongenerator/aircraft/flightgroupconfigurator.py +++ b/game/missiongenerator/aircraft/flightgroupconfigurator.py @@ -15,6 +15,7 @@ from dcs.unit import Skill from dcs.unitgroup import FlyingGroup from game.ato import Flight, FlightType +from game.ato.flightplans.shiprecoverytanker import RecoveryTankerFlightPlan from game.callsigns import callsign_for_support_unit from game.data.weapons import Pylon, WeaponType from game.missiongenerator.logisticsgenerator import LogisticsGenerator @@ -79,12 +80,14 @@ class FlightGroupConfigurator: self.use_client = use_client def configure(self) -> FlightData: - AircraftBehavior(self.flight.flight_type).apply_to(self.flight, self.group) + flight_channel = self.setup_radios() + AircraftBehavior(self.flight.flight_type, self.mission_data).apply_to( + self.flight, self.group + ) AircraftPainter(self.flight, self.group).apply_livery() self.setup_props() self.setup_payloads() self.setup_fuel() - flight_channel = self.setup_radios() laser_codes: list[Optional[int]] = [] for unit, member in zip(self.group.units, self.flight.iter_members()): @@ -197,7 +200,11 @@ class FlightGroupConfigurator: if freq not in self.radio_registry.allocated_channels: self.radio_registry.reserve(freq) - if self.flight.flight_type in {FlightType.AEWC, FlightType.REFUELING}: + if self.flight.flight_type in { + FlightType.AEWC, + FlightType.REFUELING, + FlightType.RECOVERY, + }: self.register_air_support(freq) elif self.flight.client_count and self.flight.squadron.radio_presets: freq = self.flight.squadron.radio_presets["intra_flight"][0] @@ -222,9 +229,11 @@ class FlightGroupConfigurator: unit=self.group.units[0], ) ) - elif isinstance( - self.flight.flight_plan, TheaterRefuelingFlightPlan - ) or isinstance(self.flight.flight_plan, PackageRefuelingFlightPlan): + elif ( + isinstance(self.flight.flight_plan, TheaterRefuelingFlightPlan) + or isinstance(self.flight.flight_plan, PackageRefuelingFlightPlan) + or isinstance(self.flight.flight_plan, RecoveryTankerFlightPlan) + ): tacan = self.flight.tacan if tacan is None and self.flight.squadron.aircraft.dcs_unit_type.tacan: try: diff --git a/game/missiongenerator/aircraft/waypoints/racetrack.py b/game/missiongenerator/aircraft/waypoints/racetrack.py index 5473dbdb..72d19249 100644 --- a/game/missiongenerator/aircraft/waypoints/racetrack.py +++ b/game/missiongenerator/aircraft/waypoints/racetrack.py @@ -100,7 +100,7 @@ class RaceTrackBuilder(PydcsWaypointBuilder): ActivateBeaconCommand( tacan.number, tacan.band.value, - tacan_callsign, + tacan_callsign.upper(), bearing=True, unit_id=self.group.units[0].id, aa=True, diff --git a/game/missiongenerator/aircraft/waypoints/racetrackend.py b/game/missiongenerator/aircraft/waypoints/racetrackend.py index 5a14d345..ff8402da 100644 --- a/game/missiongenerator/aircraft/waypoints/racetrackend.py +++ b/game/missiongenerator/aircraft/waypoints/racetrackend.py @@ -9,8 +9,6 @@ from .pydcswaypointbuilder import PydcsWaypointBuilder class RaceTrackEndBuilder(PydcsWaypointBuilder): def add_tasks(self, waypoint: MovingPoint) -> None: - flight_plan = self.flight.flight_plan - # Unlimited fuel option : enable at racetrack end. Must be first option to work. if self.flight.squadron.coalition.game.settings.ai_unlimited_fuel: waypoint.tasks.insert(0, SetUnlimitedFuelCommand(True)) diff --git a/game/missiongenerator/aircraft/waypoints/refuel.py b/game/missiongenerator/aircraft/waypoints/refuel.py index 1cf8ef24..e0b85ae3 100644 --- a/game/missiongenerator/aircraft/waypoints/refuel.py +++ b/game/missiongenerator/aircraft/waypoints/refuel.py @@ -8,6 +8,17 @@ class RefuelPointBuilder(PydcsWaypointBuilder): def add_tasks(self, waypoint: MovingPoint) -> None: if not self.ai_despawn(waypoint, True): refuel = ControlledTask(RefuelingTaskAction()) - refuel.start_probability(10) + refuel.start_if_lua_predicate(self._get_lua_predicate(0.2)) + refuel.stop_if_lua_predicate(self._get_lua_predicate(0.5)) waypoint.add_task(refuel) return super().add_tasks(waypoint) + + def _get_lua_predicate(self, fuel_level: float) -> str: + return f""" + local okfuel = true + for i, unitObject in pairs(Group.getByName('{self.group.name}'):getUnits()) do + --trigger.action.outText(tostring(Unit.getFuel(unitObject)), 15) + if Unit.getFuel(unitObject) < {fuel_level} then okfuel = false; break end + end + return lowfuel + """ diff --git a/game/missiongenerator/missiondata.py b/game/missiongenerator/missiondata.py index 83fdda07..cbcac39c 100644 --- a/game/missiongenerator/missiondata.py +++ b/game/missiongenerator/missiondata.py @@ -5,6 +5,7 @@ from datetime import datetime from typing import Optional, TYPE_CHECKING from dcs.flyingunit import FlyingUnit +from dcs.unitgroup import ShipGroup from game.dcs.aircrafttype import AircraftType from game.dcs.groundunittype import GroundUnitType @@ -58,6 +59,7 @@ class CarrierInfo(UnitInfo): tacan: TacanChannel icls_channel: int | None link4_freq: RadioFrequency | None + ship_group: ShipGroup @dataclass diff --git a/game/missiongenerator/tgogenerator.py b/game/missiongenerator/tgogenerator.py index adc58a77..3f021aab 100644 --- a/game/missiongenerator/tgogenerator.py +++ b/game/missiongenerator/tgogenerator.py @@ -650,6 +650,7 @@ class GenericCarrierGenerator(GroundObjectGenerator): icls_channel=icls, link4_freq=link4, blue=self.control_point.captured, + ship_group=ship_group, ) ) diff --git a/game/pretense/pretenseflightgroupconfigurator.py b/game/pretense/pretenseflightgroupconfigurator.py index 02226e00..58315ac6 100644 --- a/game/pretense/pretenseflightgroupconfigurator.py +++ b/game/pretense/pretenseflightgroupconfigurator.py @@ -77,12 +77,14 @@ class PretenseFlightGroupConfigurator(FlightGroupConfigurator): self.use_client = use_client def configure(self) -> FlightData: - AircraftBehavior(self.flight.flight_type).apply_to(self.flight, self.group) + flight_channel = self.setup_radios() + AircraftBehavior(self.flight.flight_type, self.mission_data).apply_to( + self.flight, self.group + ) AircraftPainter(self.flight, self.group).apply_livery() self.setup_props() self.setup_payloads() self.setup_fuel() - flight_channel = self.setup_radios() laser_codes: list[Optional[int]] = [] for unit, pilot in zip(self.group.units, self.flight.roster.members): diff --git a/game/pretense/pretensetgogenerator.py b/game/pretense/pretensetgogenerator.py index 9c36ac39..7a914aec 100644 --- a/game/pretense/pretensetgogenerator.py +++ b/game/pretense/pretensetgogenerator.py @@ -744,6 +744,7 @@ class PretenseGenericCarrierGenerator(GenericCarrierGenerator): icls_channel=icls, link4_freq=link4, blue=self.control_point.captured, + ship_group=ship_group, ) ) diff --git a/game/settings/settings.py b/game/settings/settings.py index 4326da56..a59e70c1 100644 --- a/game/settings/settings.py +++ b/game/settings/settings.py @@ -247,6 +247,17 @@ class Settings: "provided the faction has access to them." ), ) + aircraft_per_recovery_tanker: int = bounded_int_option( + "Number of aircraft per recovery tanker", + page=CAMPAIGN_DOCTRINE_PAGE, + section=GENERAL_SECTION, + default=4, + min=2, + max=12, + detail=( + "A higher number will force the autoplanner to generate less recovery tankers." + ), + ) oca_target_autoplanner_min_aircraft_count: int = bounded_int_option( "Minimum number of aircraft (at vulnerable airfields) for auto-planner to plan OCA packages against", page=CAMPAIGN_DOCTRINE_PAGE, diff --git a/game/theater/controlpoint.py b/game/theater/controlpoint.py index 3c68f417..efeae4d7 100644 --- a/game/theater/controlpoint.py +++ b/game/theater/controlpoint.py @@ -1369,6 +1369,9 @@ class NavalControlPoint( if self.is_friendly(for_player): yield from [ + FlightType.AEWC, + FlightType.RECOVERY, + FlightType.REFUELING, # TODO: FlightType.INTERCEPTION # TODO: Buddy tanking for the A-4? # TODO: Rescue chopper? @@ -1382,8 +1385,7 @@ class NavalControlPoint( yield from super().mission_types(for_player) if self.is_friendly(for_player): yield from [ - FlightType.AEWC, - FlightType.REFUELING, + # Nothing yet ] @property diff --git a/qt_ui/windows/mission/QAutoCreateDialog.py b/qt_ui/windows/mission/QAutoCreateDialog.py index c1697d06..ce6d0cf2 100644 --- a/qt_ui/windows/mission/QAutoCreateDialog.py +++ b/qt_ui/windows/mission/QAutoCreateDialog.py @@ -159,6 +159,21 @@ class QAutoCreateDialog(QDialog): self.refueling_type, ) + hbox = QHBoxLayout() + self.recovery = self._create_checkbox("Recovery") + self.recovery_count = _spinbox_template() + self.recovery_count.setValue(1) + hbox.addWidget(self.recovery) + hbox.addWidget(self.recovery_count) + self.recovery_type = self._create_type_selector(FlightType.RECOVERY) + hbox.addWidget(self.recovery_type, 1) + self.layout.addLayout(hbox) + self.checkboxes[self.recovery] = ( + FlightType.RECOVERY, + self.recovery_count, + self.recovery_type, + ) + self.create_button = QPushButton("Create") self.create_button.setProperty("style", "start-button") self.create_button.clicked.connect(self.on_create_clicked) diff --git a/resources/units/aircraft/S-3B Tanker.yaml b/resources/units/aircraft/S-3B Tanker.yaml index 3c20cced..807f03eb 100644 --- a/resources/units/aircraft/S-3B Tanker.yaml +++ b/resources/units/aircraft/S-3B Tanker.yaml @@ -26,3 +26,4 @@ variants: S-3B Tanker: {} tasks: Refueling: 0 + Recovery: 0