Recovery tanker support (#429)

* fix conflict

* squash bugs and reuse patrol layout

* fix tanker tacan and formatting

* fix unlimited fuel option

* update pretense for tanker changes

* reuse refueling flight plan and bugfix for sunken carrier

changelog

* remove unitmap dependency

* formatting and more unit map removal

* more formatting

* typing and black

* keep tanker out of clouds

* fix if there are no clouds

* better cloud handling

* groundwork for recovery task

* remove changes to game/commander

* Finishing up recovery tankers

---------

Co-authored-by: Raffson <Raffson@users.noreply.github.com>
This commit is contained in:
Druss99 2024-12-22 23:39:10 -05:00 committed by GitHub
parent a4671571bc
commit dd7e4c908e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
46 changed files with 395 additions and 25 deletions

View File

@ -41,6 +41,7 @@
* **[Mission Generation]** Enable Supercarrier's LSO & Airboss stations * **[Mission Generation]** Enable Supercarrier's LSO & Airboss stations
* **[UX]** Default settings are now loaded from Default.zip * **[UX]** Default settings are now loaded from Default.zip
* **[Autoplanner]** Plan Air-to-Air Escorts for AWACS & Tankers * **[Autoplanner]** Plan Air-to-Air Escorts for AWACS & Tankers
* **[Package Planning]** Ability to plan recovery tanker flights
## Fixes ## Fixes
* **[UI/UX]** A-10A flights can be edited again * **[UI/UX]** A-10A flights can be edited again

View File

@ -60,6 +60,16 @@ class CustomFlightPlan(FlightPlan[CustomLayout]):
def mission_departure_time(self) -> datetime: def mission_departure_time(self) -> datetime:
return self.package.time_over_target 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]): class Builder(IBuilder[CustomFlightPlan, CustomLayout]):
def __init__( def __init__(

View File

@ -296,6 +296,10 @@ class FlightPlan(ABC, Generic[LayoutT]):
"""The time that the mission is complete and the flight RTBs.""" """The time that the mission is complete and the flight RTBs."""
raise NotImplementedError raise NotImplementedError
@property
def landing_time(self) -> datetime:
raise NotImplementedError
@self_type_guard @self_type_guard
def is_loiter(self, flight_plan: FlightPlan[Any]) -> TypeGuard[LoiterFlightPlan]: def is_loiter(self, flight_plan: FlightPlan[Any]) -> TypeGuard[LoiterFlightPlan]:
return False return False

View File

@ -3,6 +3,7 @@ from __future__ import annotations
from typing import Any, TYPE_CHECKING, Type from typing import Any, TYPE_CHECKING, Type
from game.ato import FlightType from game.ato import FlightType
from game.theater.controlpoint import NavalControlPoint
from .aewc import AewcFlightPlan from .aewc import AewcFlightPlan
from .airassault import AirAssaultFlightPlan from .airassault import AirAssaultFlightPlan
from .airlift import AirliftFlightPlan from .airlift import AirliftFlightPlan
@ -22,6 +23,7 @@ from .planningerror import PlanningError
from .pretensecargo import PretenseCargoFlightPlan from .pretensecargo import PretenseCargoFlightPlan
from .sead import SeadFlightPlan from .sead import SeadFlightPlan
from .seadsweep import SeadSweepFlightPlan from .seadsweep import SeadSweepFlightPlan
from .shiprecoverytanker import RecoveryTankerFlightPlan
from .strike import StrikeFlightPlan from .strike import StrikeFlightPlan
from .sweep import SweepFlightPlan from .sweep import SweepFlightPlan
from .tarcap import TarCapFlightPlan from .tarcap import TarCapFlightPlan
@ -64,6 +66,7 @@ class FlightPlanBuilderTypes:
FlightType.AIR_ASSAULT: AirAssaultFlightPlan.builder_type(), FlightType.AIR_ASSAULT: AirAssaultFlightPlan.builder_type(),
FlightType.PRETENSE_CARGO: PretenseCargoFlightPlan.builder_type(), FlightType.PRETENSE_CARGO: PretenseCargoFlightPlan.builder_type(),
FlightType.ARMED_RECON: ArmedReconFlightPlan.builder_type(), FlightType.ARMED_RECON: ArmedReconFlightPlan.builder_type(),
FlightType.RECOVERY: RecoveryTankerFlightPlan.builder_type(),
} }
try: try:
return builder_dict[flight.flight_type] return builder_dict[flight.flight_type]

View File

@ -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())

View File

@ -3,6 +3,7 @@ from __future__ import annotations
from abc import ABC from abc import ABC
from copy import deepcopy from copy import deepcopy
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime
from typing import TYPE_CHECKING, TypeVar, Optional from typing import TYPE_CHECKING, TypeVar, Optional
from game.ato.flightplans.flightplan import FlightPlan, Layout 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, others are guaranteed to have certain properties like departure and arrival points,
potentially a divert field, and a bullseye 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

View File

@ -800,3 +800,18 @@ class WaypointBuilder:
x_adj = random.randint(int(-deviation.meters), int(deviation.meters)) x_adj = random.randint(int(-deviation.meters), int(deviation.meters))
y_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) 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
)

View File

@ -60,6 +60,7 @@ class FlightType(Enum):
SEAD_SWEEP = "SEAD Sweep" # Reintroduce legacy "engage-whatever-you-can-find" SEAD SEAD_SWEEP = "SEAD Sweep" # Reintroduce legacy "engage-whatever-you-can-find" SEAD
PRETENSE_CARGO = "Cargo Transport" # For Pretense campaign AI cargo planes PRETENSE_CARGO = "Cargo Transport" # For Pretense campaign AI cargo planes
ARMED_RECON = "Armed Recon" ARMED_RECON = "Armed Recon"
RECOVERY = "Recovery"
def __str__(self) -> str: def __str__(self) -> str:
return self.value return self.value
@ -117,6 +118,7 @@ class FlightType(Enum):
FlightType.INTERCEPTION: AirEntity.FIGHTER, FlightType.INTERCEPTION: AirEntity.FIGHTER,
FlightType.OCA_AIRCRAFT: AirEntity.ATTACK_STRIKE, FlightType.OCA_AIRCRAFT: AirEntity.ATTACK_STRIKE,
FlightType.OCA_RUNWAY: AirEntity.ATTACK_STRIKE, FlightType.OCA_RUNWAY: AirEntity.ATTACK_STRIKE,
FlightType.RECOVERY: AirEntity.TANKER,
FlightType.REFUELING: AirEntity.TANKER, FlightType.REFUELING: AirEntity.TANKER,
FlightType.SEAD: AirEntity.SUPPRESSION_OF_ENEMY_AIR_DEFENCE, FlightType.SEAD: AirEntity.SUPPRESSION_OF_ENEMY_AIR_DEFENCE,
FlightType.SEAD_ESCORT: AirEntity.SUPPRESSION_OF_ENEMY_AIR_DEFENCE, FlightType.SEAD_ESCORT: AirEntity.SUPPRESSION_OF_ENEMY_AIR_DEFENCE,

View File

@ -52,3 +52,4 @@ class FlightWaypointType(IntEnum):
INGRESS_ANTI_SHIP = 32 INGRESS_ANTI_SHIP = 32
INGRESS_SEAD_SWEEP = 33 INGRESS_SEAD_SWEEP = 33
INGRESS_ARMED_RECON = 34 INGRESS_ARMED_RECON = 34
RECOVERY_TANKER = 35 # Tanker recovery point

View File

@ -188,6 +188,7 @@ class Package(RadioFrequencyContainer):
FlightType.BARCAP, FlightType.BARCAP,
FlightType.AEWC, FlightType.AEWC,
FlightType.FERRY, FlightType.FERRY,
FlightType.RECOVERY,
FlightType.REFUELING, FlightType.REFUELING,
FlightType.SWEEP, FlightType.SWEEP,
FlightType.SEAD_ESCORT, FlightType.SEAD_ESCORT,

View File

@ -40,6 +40,8 @@ class MissionScheduler:
p for p in self.coalition.ato.packages if p.primary_task not in dca_types p for p in self.coalition.ato.packages if p.primary_task not in dca_types
] ]
carrier_etas = []
start_time = start_time_generator( start_time = start_time_generator(
count=len(non_dca_packages), count=len(non_dca_packages),
earliest=5 * 60, earliest=5 * 60,
@ -47,6 +49,8 @@ class MissionScheduler:
margin=5 * 60, margin=5 * 60,
) )
for package in self.coalition.ato.packages: for package in self.coalition.ato.packages:
if package.primary_task is FlightType.RECOVERY:
continue
tot = TotEstimator(package).earliest_tot(now) tot = TotEstimator(package).earliest_tot(now)
if package.primary_task in dca_types: if package.primary_task in dca_types:
previous_end_time = previous_cap_end_time[package.target] 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 # to be present. Runway and air started aircraft will be
# delayed until their takeoff time by AirConflictGenerator. # delayed until their takeoff time by AirConflictGenerator.
package.time_over_target = next(start_time) + tot 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

View File

@ -246,6 +246,9 @@ class ObjectiveFinder:
raise RuntimeError("Found no friendly control points. You probably lost.") raise RuntimeError("Found no friendly control points. You probably lost.")
return closest 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]: def enemy_control_points(self) -> Iterator[ControlPoint]:
"""Iterates over all enemy control points.""" """Iterates over all enemy control points."""
return ( return (

View File

@ -53,7 +53,8 @@ class PackageBuilder:
if pf: if pf:
target = ( target = (
pf.departure pf.departure
if pf.flight_type in [FlightType.AEWC, FlightType.REFUELING] if pf.flight_type
in [FlightType.AEWC, FlightType.REFUELING, FlightType.RECOVERY]
else target else target
) )
heli = pf.is_helo heli = pf.is_helo

View File

@ -14,6 +14,7 @@ from game.commander.tasks.compound.interdictreinforcements import (
InterdictReinforcements, InterdictReinforcements,
) )
from game.commander.tasks.compound.protectairspace import ProtectAirSpace 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.tasks.compound.theatersupport import TheaterSupport
from game.commander.theaterstate import TheaterState from game.commander.theaterstate import TheaterState
from game.htn import CompoundTask, Method from game.htn import CompoundTask, Method
@ -34,3 +35,4 @@ class PlanNextAction(CompoundTask[TheaterState]):
yield [AttackBuildings()] yield [AttackBuildings()]
yield [AttackShips()] yield [AttackShips()]
yield [DegradeIads()] yield [DegradeIads()]
yield [RecoverySupport()] # for recovery tankers

View File

@ -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)]

View File

@ -16,7 +16,7 @@ from game.commander.tasks.theatercommandertask import TheaterCommanderTask
from game.commander.theaterstate import TheaterState from game.commander.theaterstate import TheaterState
from game.data.groups import GroupTask from game.data.groups import GroupTask
from game.settings import AutoAtoBehavior 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.theater.theatergroundobject import IadsGroundObject, NavalGroundObject
from game.utils import Distance, meters from game.utils import Distance, meters
@ -51,6 +51,15 @@ class PackagePlanningTask(TheaterCommanderTask, Generic[MissionTargetT]):
return False return False
return self.fulfill_mission(state) 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: def execute(self, coalition: Coalition) -> None:
if self.package is None: if self.package is None:
raise RuntimeError("Attempted to execute failed package planning task") raise RuntimeError("Attempted to execute failed package planning task")

View File

@ -22,6 +22,7 @@ class PlanAewc(PackagePlanningTask[MissionTarget]):
def apply_effects(self, state: TheaterState) -> None: def apply_effects(self, state: TheaterState) -> None:
state.aewc_targets.remove(self.target) state.aewc_targets.remove(self.target)
super().apply_effects(state)
def propose_flights(self) -> None: def propose_flights(self) -> None:
self.propose_flight(FlightType.AEWC, 1) self.propose_flight(FlightType.AEWC, 1)

View File

@ -19,6 +19,7 @@ class PlanAirAssault(PackagePlanningTask[ControlPoint]):
def apply_effects(self, state: TheaterState) -> None: def apply_effects(self, state: TheaterState) -> None:
state.vulnerable_control_points.remove(self.target) state.vulnerable_control_points.remove(self.target)
super().apply_effects(state)
def propose_flights(self) -> None: def propose_flights(self) -> None:
self.propose_flight(FlightType.AIR_ASSAULT, self.get_flight_size()) self.propose_flight(FlightType.AIR_ASSAULT, self.get_flight_size())

View File

@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from random import randint
from game.ato.flighttype import FlightType from game.ato.flighttype import FlightType
from game.commander.missionproposals import EscortType from game.commander.missionproposals import EscortType
@ -21,6 +20,7 @@ class PlanAntiShip(PackagePlanningTask[NavalGroundObject]):
def apply_effects(self, state: TheaterState) -> None: def apply_effects(self, state: TheaterState) -> None:
state.eliminate_ship(self.target) state.eliminate_ship(self.target)
super().apply_effects(state)
def propose_flights(self) -> None: def propose_flights(self) -> None:
size = self.get_flight_size() size = self.get_flight_size()

View File

@ -20,6 +20,7 @@ class PlanAntiShipping(PackagePlanningTask[CargoShip]):
def apply_effects(self, state: TheaterState) -> None: def apply_effects(self, state: TheaterState) -> None:
state.enemy_shipping.remove(self.target) state.enemy_shipping.remove(self.target)
super().apply_effects(state)
def propose_flights(self) -> None: def propose_flights(self) -> None:
size = self.get_flight_size() size = self.get_flight_size()

View File

@ -19,6 +19,7 @@ class PlanArmedRecon(PackagePlanningTask[ControlPoint]):
def apply_effects(self, state: TheaterState) -> None: def apply_effects(self, state: TheaterState) -> None:
state.control_point_priority_queue.remove(self.target) state.control_point_priority_queue.remove(self.target)
super().apply_effects(state)
def propose_flights(self) -> None: def propose_flights(self) -> None:
self.propose_flight(FlightType.ARMED_RECON, self.get_flight_size()) self.propose_flight(FlightType.ARMED_RECON, self.get_flight_size())

View File

@ -19,6 +19,7 @@ class PlanBai(PackagePlanningTask[VehicleGroupGroundObject]):
def apply_effects(self, state: TheaterState) -> None: def apply_effects(self, state: TheaterState) -> None:
state.eliminate_battle_position(self.target) state.eliminate_battle_position(self.target)
super().apply_effects(state)
def propose_flights(self) -> None: def propose_flights(self) -> None:
tgt_count = self.target.alive_unit_count tgt_count = self.target.alive_unit_count

View File

@ -1,8 +1,6 @@
from __future__ import annotations from __future__ import annotations
import random
from dataclasses import dataclass from dataclasses import dataclass
from random import randint
from game.ato.flighttype import FlightType from game.ato.flighttype import FlightType
from game.commander.tasks.packageplanningtask import PackagePlanningTask from game.commander.tasks.packageplanningtask import PackagePlanningTask
@ -21,6 +19,7 @@ class PlanBarcap(PackagePlanningTask[ControlPoint]):
def apply_effects(self, state: TheaterState) -> None: def apply_effects(self, state: TheaterState) -> None:
state.barcaps_needed[self.target] -= 1 state.barcaps_needed[self.target] -= 1
super().apply_effects(state)
def propose_flights(self) -> None: def propose_flights(self) -> None:
size = self.get_flight_size() size = self.get_flight_size()

View File

@ -28,6 +28,7 @@ class PlanCas(PackagePlanningTask[FrontLine]):
def apply_effects(self, state: TheaterState) -> None: def apply_effects(self, state: TheaterState) -> None:
state.vulnerable_front_lines.remove(self.target) state.vulnerable_front_lines.remove(self.target)
super().apply_effects(state)
def propose_flights(self) -> None: def propose_flights(self) -> None:
size = self.get_flight_size() size = self.get_flight_size()

View File

@ -19,6 +19,7 @@ class PlanConvoyInterdiction(PackagePlanningTask[Convoy]):
def apply_effects(self, state: TheaterState) -> None: def apply_effects(self, state: TheaterState) -> None:
state.enemy_convoys.remove(self.target) state.enemy_convoys.remove(self.target)
super().apply_effects(state)
def propose_flights(self) -> None: def propose_flights(self) -> None:
self.propose_flight(FlightType.BAI, 2) self.propose_flight(FlightType.BAI, 2)

View File

@ -23,6 +23,7 @@ class PlanDead(PackagePlanningTask[IadsGroundObject]):
def apply_effects(self, state: TheaterState) -> None: def apply_effects(self, state: TheaterState) -> None:
state.eliminate_air_defense(self.target) state.eliminate_air_defense(self.target)
super().apply_effects(state)
def propose_flights(self) -> None: def propose_flights(self) -> None:
tgt_count = self.target.alive_unit_count tgt_count = self.target.alive_unit_count

View File

@ -22,6 +22,7 @@ class PlanOcaStrike(PackagePlanningTask[ControlPoint]):
def apply_effects(self, state: TheaterState) -> None: def apply_effects(self, state: TheaterState) -> None:
state.oca_targets.remove(self.target) state.oca_targets.remove(self.target)
super().apply_effects(state)
def propose_flights(self) -> None: def propose_flights(self) -> None:
size = self.get_flight_size() size = self.get_flight_size()

View File

@ -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)

View File

@ -22,6 +22,7 @@ class PlanRefueling(PackagePlanningTask[MissionTarget]):
def apply_effects(self, state: TheaterState) -> None: def apply_effects(self, state: TheaterState) -> None:
state.refueling_targets.remove(self.target) state.refueling_targets.remove(self.target)
super().apply_effects(state)
def propose_flights(self) -> None: def propose_flights(self) -> None:
self.propose_flight(FlightType.REFUELING, 1) self.propose_flight(FlightType.REFUELING, 1)

View File

@ -20,6 +20,7 @@ class PlanStrike(PackagePlanningTask[TheaterGroundObject]):
def apply_effects(self, state: TheaterState) -> None: def apply_effects(self, state: TheaterState) -> None:
state.strike_targets.remove(self.target) state.strike_targets.remove(self.target)
super().apply_effects(state)
def propose_flights(self) -> None: def propose_flights(self) -> None:
tgt_count = self.target.alive_unit_count tgt_count = self.target.alive_unit_count

View File

@ -15,7 +15,12 @@ from game.ground_forces.combat_stance import CombatStance
from game.htn import WorldState from game.htn import WorldState
from game.profiling import MultiEventTracer from game.profiling import MultiEventTracer
from game.settings import Settings 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 ( from game.theater.theatergroundobject import (
BuildingGroundObject, BuildingGroundObject,
IadsGroundObject, IadsGroundObject,
@ -51,6 +56,7 @@ class TheaterState(WorldState["TheaterState"]):
vulnerable_front_lines: list[FrontLine] vulnerable_front_lines: list[FrontLine]
aewc_targets: list[MissionTarget] aewc_targets: list[MissionTarget]
refueling_targets: list[MissionTarget] refueling_targets: list[MissionTarget]
recovery_targets: dict[ControlPoint, int]
enemy_air_defenses: list[IadsGroundObject] enemy_air_defenses: list[IadsGroundObject]
threatening_air_defenses: list[Union[IadsGroundObject, NavalGroundObject]] threatening_air_defenses: list[Union[IadsGroundObject, NavalGroundObject]]
detecting_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), vulnerable_front_lines=list(self.vulnerable_front_lines),
aewc_targets=list(self.aewc_targets), aewc_targets=list(self.aewc_targets),
refueling_targets=list(self.refueling_targets), refueling_targets=list(self.refueling_targets),
recovery_targets=dict(self.recovery_targets),
enemy_air_defenses=list(self.enemy_air_defenses), enemy_air_defenses=list(self.enemy_air_defenses),
enemy_convoys=list(self.enemy_convoys), enemy_convoys=list(self.enemy_convoys),
enemy_shipping=list(self.enemy_shipping), enemy_shipping=list(self.enemy_shipping),
@ -186,6 +193,7 @@ class TheaterState(WorldState["TheaterState"]):
vulnerable_front_lines=list(finder.front_lines()), vulnerable_front_lines=list(finder.front_lines()),
aewc_targets=[finder.farthest_friendly_control_point()], aewc_targets=[finder.farthest_friendly_control_point()],
refueling_targets=[finder.closest_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()), enemy_air_defenses=list(finder.enemy_air_defenses()),
threatening_air_defenses=[], threatening_air_defenses=[],
detecting_air_defenses=[], detecting_air_defenses=[],

View File

@ -262,6 +262,12 @@ class AircraftType(UnitType[Type[FlyingType]]):
): ):
enrich[FlightType.ARMED_RECON] = value 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) self.task_priorities.update(enrich)
def __eq__(self, other: object) -> bool: def __eq__(self, other: object) -> bool:

View File

@ -554,6 +554,7 @@ class Game:
FlightType.TRANSPORT, FlightType.TRANSPORT,
FlightType.AEWC, FlightType.AEWC,
FlightType.REFUELING, FlightType.REFUELING,
FlightType.RECOVERY,
]: ]:
# BARCAPs will be planned at most locations on smaller theaters, # BARCAPs will be planned at most locations on smaller theaters,
# rendering culling fairly useless. BARCAP packages don't really # rendering culling fairly useless. BARCAP packages don't really

View File

@ -30,22 +30,33 @@ from dcs.task import (
OptNoReportWaypointPass, OptNoReportWaypointPass,
OptRadioUsageContact, OptRadioUsageContact,
OptRadioSilence, 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.aewc import AewcFlightPlan
from game.ato.flightplans.formationattack import FormationAttackLayout from game.ato.flightplans.formationattack import FormationAttackLayout
from game.ato.flightplans.packagerefueling import PackageRefuelingFlightPlan from game.ato.flightplans.packagerefueling import PackageRefuelingFlightPlan
from game.ato.flightplans.shiprecoverytanker import RecoveryTankerFlightPlan
from game.ato.flightplans.theaterrefueling import TheaterRefuelingFlightPlan 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: class AircraftBehavior:
def __init__(self, task: FlightType) -> None: def __init__(self, task: FlightType, mission_data: MissionData) -> None:
self.task = task 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 [ if self.task in [
FlightType.BARCAP, FlightType.BARCAP,
FlightType.TARCAP, FlightType.TARCAP,
@ -58,6 +69,8 @@ class AircraftBehavior:
self.configure_awacs(group, flight) self.configure_awacs(group, flight)
elif self.task == FlightType.REFUELING: elif self.task == FlightType.REFUELING:
self.configure_refueling(group, flight) self.configure_refueling(group, flight)
elif self.task == FlightType.RECOVERY:
self.configure_recovery(group, flight)
elif self.task in [FlightType.CAS, FlightType.BAI]: elif self.task in [FlightType.CAS, FlightType.BAI]:
self.configure_cas(group, flight) self.configure_cas(group, flight)
elif self.task == FlightType.ARMED_RECON: elif self.task == FlightType.ARMED_RECON:
@ -317,6 +330,7 @@ class AircraftBehavior:
if not ( if not (
isinstance(flight.flight_plan, TheaterRefuelingFlightPlan) isinstance(flight.flight_plan, TheaterRefuelingFlightPlan)
or isinstance(flight.flight_plan, PackageRefuelingFlightPlan) or isinstance(flight.flight_plan, PackageRefuelingFlightPlan)
or isinstance(flight.flight_plan, RecoveryTankerFlightPlan)
): ):
logging.error( logging.error(
f"Cannot configure racetrack refueling tasks for {flight} because it " f"Cannot configure racetrack refueling tasks for {flight} because it "
@ -332,6 +346,95 @@ class AircraftBehavior:
restrict_jettison=True, 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: def configure_escort(self, group: FlyingGroup[Any], flight: Flight) -> None:
self.configure_task(flight, group, Escort) self.configure_task(flight, group, Escort)
self.configure_behavior( self.configure_behavior(

View File

@ -15,6 +15,7 @@ from dcs.unit import Skill
from dcs.unitgroup import FlyingGroup from dcs.unitgroup import FlyingGroup
from game.ato import Flight, FlightType from game.ato import Flight, FlightType
from game.ato.flightplans.shiprecoverytanker import RecoveryTankerFlightPlan
from game.callsigns import callsign_for_support_unit from game.callsigns import callsign_for_support_unit
from game.data.weapons import Pylon, WeaponType from game.data.weapons import Pylon, WeaponType
from game.missiongenerator.logisticsgenerator import LogisticsGenerator from game.missiongenerator.logisticsgenerator import LogisticsGenerator
@ -79,12 +80,14 @@ class FlightGroupConfigurator:
self.use_client = use_client self.use_client = use_client
def configure(self) -> FlightData: 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() AircraftPainter(self.flight, self.group).apply_livery()
self.setup_props() self.setup_props()
self.setup_payloads() self.setup_payloads()
self.setup_fuel() self.setup_fuel()
flight_channel = self.setup_radios()
laser_codes: list[Optional[int]] = [] laser_codes: list[Optional[int]] = []
for unit, member in zip(self.group.units, self.flight.iter_members()): 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: if freq not in self.radio_registry.allocated_channels:
self.radio_registry.reserve(freq) 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) self.register_air_support(freq)
elif self.flight.client_count and self.flight.squadron.radio_presets: elif self.flight.client_count and self.flight.squadron.radio_presets:
freq = self.flight.squadron.radio_presets["intra_flight"][0] freq = self.flight.squadron.radio_presets["intra_flight"][0]
@ -222,9 +229,11 @@ class FlightGroupConfigurator:
unit=self.group.units[0], unit=self.group.units[0],
) )
) )
elif isinstance( elif (
self.flight.flight_plan, TheaterRefuelingFlightPlan isinstance(self.flight.flight_plan, TheaterRefuelingFlightPlan)
) or isinstance(self.flight.flight_plan, PackageRefuelingFlightPlan): or isinstance(self.flight.flight_plan, PackageRefuelingFlightPlan)
or isinstance(self.flight.flight_plan, RecoveryTankerFlightPlan)
):
tacan = self.flight.tacan tacan = self.flight.tacan
if tacan is None and self.flight.squadron.aircraft.dcs_unit_type.tacan: if tacan is None and self.flight.squadron.aircraft.dcs_unit_type.tacan:
try: try:

View File

@ -100,7 +100,7 @@ class RaceTrackBuilder(PydcsWaypointBuilder):
ActivateBeaconCommand( ActivateBeaconCommand(
tacan.number, tacan.number,
tacan.band.value, tacan.band.value,
tacan_callsign, tacan_callsign.upper(),
bearing=True, bearing=True,
unit_id=self.group.units[0].id, unit_id=self.group.units[0].id,
aa=True, aa=True,

View File

@ -9,8 +9,6 @@ from .pydcswaypointbuilder import PydcsWaypointBuilder
class RaceTrackEndBuilder(PydcsWaypointBuilder): class RaceTrackEndBuilder(PydcsWaypointBuilder):
def add_tasks(self, waypoint: MovingPoint) -> None: 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. # Unlimited fuel option : enable at racetrack end. Must be first option to work.
if self.flight.squadron.coalition.game.settings.ai_unlimited_fuel: if self.flight.squadron.coalition.game.settings.ai_unlimited_fuel:
waypoint.tasks.insert(0, SetUnlimitedFuelCommand(True)) waypoint.tasks.insert(0, SetUnlimitedFuelCommand(True))

View File

@ -8,6 +8,17 @@ class RefuelPointBuilder(PydcsWaypointBuilder):
def add_tasks(self, waypoint: MovingPoint) -> None: def add_tasks(self, waypoint: MovingPoint) -> None:
if not self.ai_despawn(waypoint, True): if not self.ai_despawn(waypoint, True):
refuel = ControlledTask(RefuelingTaskAction()) 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) waypoint.add_task(refuel)
return super().add_tasks(waypoint) 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
"""

View File

@ -5,6 +5,7 @@ from datetime import datetime
from typing import Optional, TYPE_CHECKING from typing import Optional, TYPE_CHECKING
from dcs.flyingunit import FlyingUnit from dcs.flyingunit import FlyingUnit
from dcs.unitgroup import ShipGroup
from game.dcs.aircrafttype import AircraftType from game.dcs.aircrafttype import AircraftType
from game.dcs.groundunittype import GroundUnitType from game.dcs.groundunittype import GroundUnitType
@ -58,6 +59,7 @@ class CarrierInfo(UnitInfo):
tacan: TacanChannel tacan: TacanChannel
icls_channel: int | None icls_channel: int | None
link4_freq: RadioFrequency | None link4_freq: RadioFrequency | None
ship_group: ShipGroup
@dataclass @dataclass

View File

@ -650,6 +650,7 @@ class GenericCarrierGenerator(GroundObjectGenerator):
icls_channel=icls, icls_channel=icls,
link4_freq=link4, link4_freq=link4,
blue=self.control_point.captured, blue=self.control_point.captured,
ship_group=ship_group,
) )
) )

View File

@ -77,12 +77,14 @@ class PretenseFlightGroupConfigurator(FlightGroupConfigurator):
self.use_client = use_client self.use_client = use_client
def configure(self) -> FlightData: 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() AircraftPainter(self.flight, self.group).apply_livery()
self.setup_props() self.setup_props()
self.setup_payloads() self.setup_payloads()
self.setup_fuel() self.setup_fuel()
flight_channel = self.setup_radios()
laser_codes: list[Optional[int]] = [] laser_codes: list[Optional[int]] = []
for unit, pilot in zip(self.group.units, self.flight.roster.members): for unit, pilot in zip(self.group.units, self.flight.roster.members):

View File

@ -744,6 +744,7 @@ class PretenseGenericCarrierGenerator(GenericCarrierGenerator):
icls_channel=icls, icls_channel=icls,
link4_freq=link4, link4_freq=link4,
blue=self.control_point.captured, blue=self.control_point.captured,
ship_group=ship_group,
) )
) )

View File

@ -247,6 +247,17 @@ class Settings:
"provided the faction has access to them." "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( 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", "Minimum number of aircraft (at vulnerable airfields) for auto-planner to plan OCA packages against",
page=CAMPAIGN_DOCTRINE_PAGE, page=CAMPAIGN_DOCTRINE_PAGE,

View File

@ -1369,6 +1369,9 @@ class NavalControlPoint(
if self.is_friendly(for_player): if self.is_friendly(for_player):
yield from [ yield from [
FlightType.AEWC,
FlightType.RECOVERY,
FlightType.REFUELING,
# TODO: FlightType.INTERCEPTION # TODO: FlightType.INTERCEPTION
# TODO: Buddy tanking for the A-4? # TODO: Buddy tanking for the A-4?
# TODO: Rescue chopper? # TODO: Rescue chopper?
@ -1382,8 +1385,7 @@ class NavalControlPoint(
yield from super().mission_types(for_player) yield from super().mission_types(for_player)
if self.is_friendly(for_player): if self.is_friendly(for_player):
yield from [ yield from [
FlightType.AEWC, # Nothing yet
FlightType.REFUELING,
] ]
@property @property

View File

@ -159,6 +159,21 @@ class QAutoCreateDialog(QDialog):
self.refueling_type, 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 = QPushButton("Create")
self.create_button.setProperty("style", "start-button") self.create_button.setProperty("style", "start-button")
self.create_button.clicked.connect(self.on_create_clicked) self.create_button.clicked.connect(self.on_create_clicked)

View File

@ -26,3 +26,4 @@ variants:
S-3B Tanker: {} S-3B Tanker: {}
tasks: tasks:
Refueling: 0 Refueling: 0
Recovery: 0