Add Recovery Tankers (#2661)

Add support for recovery tankers at aircraft carriers.

Cherry picked from 9a81121ac15481094f43d628ebeb1f0ba5ec182e and
0fd0f0e7c06f6e633e9438889f0c5a9cbe26a42b
This commit is contained in:
SnappyComebacks 2022-12-20 22:17:02 -07:00 committed by GitHub
parent 715c60583a
commit 5bf5b41f2d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 215 additions and 14 deletions

View File

@ -6,6 +6,8 @@ Saves from 6.0.0 are compatible with 6.1.0
* **[Factions]** Defaulted bluefor modern to use Georgian and Ukrainian liveries for Russian aircraft.
* **[Factions]** Added Peru.
* **[Flight Planning]** AEW&C and Refueling flights are now plannable on LHA carriers.
* **[Flight Planning]** Refueling flights planned on aircraft carriers will act as a recovery tanker for the carrier.
* **[Loadouts]** Adjusted F-15E loadouts.
* **[Mission Generation]** The previous turn will now be saved as last_turn.liberation when submitting mission results. This is often essential for debugging bug reports. **Include this file in the bug report whenever it is available.**
* **[Modding]** Added support for the HMS Ariadne, Achilles, and Castle class.

View File

@ -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
@ -19,6 +20,7 @@ from .ocarunway import OcaRunwayFlightPlan
from .packagerefueling import PackageRefuelingFlightPlan
from .planningerror import PlanningError
from .sead import SeadFlightPlan
from .shiprecoverytanker import RecoveryTankerFlightPlan
from .strike import StrikeFlightPlan
from .sweep import SweepFlightPlan
from .tarcap import TarCapFlightPlan
@ -33,8 +35,13 @@ class FlightPlanBuilderTypes:
@staticmethod
def for_flight(flight: Flight) -> Type[IBuilder[Any, Any]]:
if flight.flight_type is FlightType.REFUELING:
if flight.package.target.is_friendly(flight.squadron.player) or isinstance(
flight.package.target, FrontLine
target = flight.package.target
if target.is_friendly(flight.squadron.player) and isinstance(
target, NavalControlPoint
):
return RecoveryTankerFlightPlan.builder_type()
if target.is_friendly(flight.squadron.player) or isinstance(
target, FrontLine
):
return TheaterRefuelingFlightPlan.builder_type()
return PackageRefuelingFlightPlan.builder_type()

View File

@ -0,0 +1,91 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
from typing import Iterator, Type
from game.ato.flightplans.standard import StandardFlightPlan, StandardLayout
from game.ato.flightplans.ibuilder import IBuilder
from game.ato.flightplans.standard import StandardLayout
from game.ato.flightplans.waypointbuilder import WaypointBuilder
from game.ato.flightwaypoint import FlightWaypoint
@dataclass(frozen=True)
class RecoveryTankerLayout(StandardLayout):
nav_to: list[FlightWaypoint]
recovery_ship: FlightWaypoint
nav_from: list[FlightWaypoint]
def iter_waypoints(self) -> Iterator[FlightWaypoint]:
yield self.departure
yield from self.nav_to
yield self.recovery_ship
yield from self.nav_from
yield self.arrival
if self.divert is not None:
yield self.divert
yield self.bullseye
class RecoveryTankerFlightPlan(StandardFlightPlan[RecoveryTankerLayout]):
@staticmethod
def builder_type() -> Type[Builder]:
return Builder
@property
def tot_waypoint(self) -> FlightWaypoint:
return self.layout.recovery_ship
@property
def mission_departure_time(self) -> timedelta:
return self.patrol_end_time
@property
def patrol_start_time(self) -> timedelta:
return self.package.time_over_target
@property
def patrol_end_time(self) -> timedelta:
return self.tot + timedelta(hours=2)
def tot_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None:
if waypoint == self.tot_waypoint:
return self.tot
return None
def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None:
if waypoint == self.tot_waypoint:
return self.mission_departure_time
return None
class Builder(IBuilder[RecoveryTankerFlightPlan, RecoveryTankerLayout]):
def layout(self) -> RecoveryTankerLayout:
builder = WaypointBuilder(self.flight, self.coalition)
# TODO: Propagate the ship position to the Tanker's TOT,
# so that we minimize the tanker's need to catch up to the carrier.
recovery_ship = self.package.target.position
recovery_tanker = builder.recovery_tanker(recovery_ship)
# We don't have per aircraft cruise altitudes, so just reuse patrol altitude?
tanker_type = self.flight.unit_type
nav_cruise_altitude = tanker_type.preferred_patrol_altitude
return RecoveryTankerLayout(
departure=builder.takeoff(self.flight.departure),
nav_to=builder.nav_path(
self.flight.departure.position, recovery_ship, nav_cruise_altitude
),
nav_from=builder.nav_path(
recovery_ship, self.flight.arrival.position, nav_cruise_altitude
),
recovery_ship=recovery_tanker,
arrival=builder.land(self.flight.arrival),
divert=builder.divert(self.flight.divert),
bullseye=builder.bullseye(),
)
def build(self) -> RecoveryTankerFlightPlan:
return RecoveryTankerFlightPlan(self.flight, self.layout())

View File

@ -23,7 +23,7 @@ from game.theater import (
TheaterGroundObject,
TheaterUnit,
)
from game.utils import Distance, meters, nautical_miles
from game.utils import Distance, feet, meters, nautical_miles
if TYPE_CHECKING:
from game.coalition import Coalition
@ -204,6 +204,19 @@ class WaypointBuilder:
pretty_name="Refuel",
)
def recovery_tanker(self, 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",
)
def split(self, position: Point) -> FlightWaypoint:
alt_type: AltitudeReference = "BARO"
if self.is_helo:

View File

@ -49,3 +49,4 @@ class FlightWaypointType(IntEnum):
REFUEL = 29 # Should look for nearby tanker to refuel from.
CARGO_STOP = 30 # Stopover landing point using the LandingReFuAr waypoint type
INGRESS_AIR_ASSAULT = 31
RECOVERY_TANKER = 32

View File

@ -24,6 +24,7 @@ from dcs.unitgroup import FlyingGroup
from game.ato import Flight, FlightType
from game.ato.flightplans.aewc import AewcFlightPlan
from game.ato.flightplans.shiprecoverytanker import RecoveryTankerFlightPlan
from game.ato.flightplans.theaterrefueling import TheaterRefuelingFlightPlan
@ -246,7 +247,10 @@ class AircraftBehavior:
def configure_refueling(self, group: FlyingGroup[Any], flight: Flight) -> None:
group.task = Refueling.name
if not isinstance(flight.flight_plan, TheaterRefuelingFlightPlan):
if not (
isinstance(flight.flight_plan, TheaterRefuelingFlightPlan)
or isinstance(flight.flight_plan, RecoveryTankerFlightPlan)
):
logging.error(
f"Cannot configure racetrack refueling tasks for {flight} because it "
"does not have an racetrack refueling flight plan."

View File

@ -177,6 +177,7 @@ class AircraftGenerator:
self.mission_data,
dynamic_runways,
self.use_client,
self.unit_map,
).configure()
)
return group

View File

@ -10,6 +10,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 as WeaponTypeEnum
from game.missiongenerator.missiondata import MissionData, AwacsInfo, TankerInfo
@ -19,6 +20,7 @@ from game.radio.radios import RadioFrequency, RadioRegistry
from game.radio.tacan import TacanBand, TacanRegistry, TacanUsage
from game.runways import RunwayData
from game.squadrons import Pilot
from game.unitmap import UnitMap
from .aircraftbehavior import AircraftBehavior
from .aircraftpainter import AircraftPainter
from .flightdata import FlightData
@ -44,6 +46,7 @@ class FlightGroupConfigurator:
mission_data: MissionData,
dynamic_runways: dict[str, RunwayData],
use_client: bool,
unit_map: UnitMap,
) -> None:
self.flight = flight
self.group = group
@ -56,6 +59,7 @@ class FlightGroupConfigurator:
self.mission_data = mission_data
self.dynamic_runways = dynamic_runways
self.use_client = use_client
self.unit_map = unit_map
def configure(self) -> FlightData:
AircraftBehavior(self.flight.flight_type).apply_to(self.flight, self.group)
@ -97,6 +101,7 @@ class FlightGroupConfigurator:
self.time,
self.game.settings,
self.mission_data,
self.unit_map,
).create_waypoints()
return FlightData(
@ -156,7 +161,9 @@ class FlightGroupConfigurator:
blue=self.flight.departure.captured,
)
)
elif isinstance(self.flight.flight_plan, TheaterRefuelingFlightPlan):
elif isinstance(
self.flight.flight_plan, TheaterRefuelingFlightPlan
) or isinstance(self.flight.flight_plan, RecoveryTankerFlightPlan):
tacan = self.tacan_registry.alloc_for_band(TacanBand.Y, TacanUsage.AirToAir)
self.mission_data.tankers.append(
TankerInfo(

View File

@ -12,6 +12,7 @@ from game.ato import Flight, FlightWaypoint
from game.ato.flightwaypointtype import FlightWaypointType
from game.missiongenerator.missiondata import MissionData
from game.theater import MissionTarget, TheaterUnit
from game.unitmap import UnitMap
TARGET_WAYPOINTS = (
FlightWaypointType.TARGET_GROUP_LOC,
@ -29,6 +30,7 @@ class PydcsWaypointBuilder:
mission: Mission,
elapsed_mission_time: timedelta,
mission_data: MissionData,
unit_map: UnitMap,
) -> None:
self.waypoint = waypoint
self.group = group
@ -37,6 +39,7 @@ class PydcsWaypointBuilder:
self.mission = mission
self.elapsed_mission_time = elapsed_mission_time
self.mission_data = mission_data
self.unit_map = unit_map
def build(self) -> MovingPoint:
waypoint = self.group.add_waypoint(

View File

@ -0,0 +1,59 @@
from dcs.point import MovingPoint
from dcs.task import ActivateBeaconCommand, RecoveryTanker
from game.ato import FlightType
from game.utils import feet, knots
from .pydcswaypointbuilder import PydcsWaypointBuilder
class RecoveryTankerBuilder(PydcsWaypointBuilder):
def add_tasks(self, waypoint: MovingPoint) -> None:
assert self.flight.flight_type == FlightType.REFUELING
group_id = self._get_carrier_group_id()
speed = knots(250).meters_per_second
altitude = feet(6000).meters
# Last waypoint has index of 1.
# Give the tanker a end condition of the last carrier waypoint.
# If the carrier ever gets more than one waypoint this approach needs to change.
last_waypoint = 2
recovery_tanker = RecoveryTanker(group_id, speed, altitude, last_waypoint)
waypoint.add_task(recovery_tanker)
self.configure_tanker_tacan(waypoint)
def _get_carrier_group_id(self) -> int:
name = self.package.target.name
carrier_position = self.package.target.position
theater_objects = self.unit_map.theater_objects
for key, value in theater_objects.items():
# Check name and position in case there are multiple of same carrier.
if name in key and value.theater_unit.position == carrier_position:
theater_mapping = value
break
assert theater_mapping is not None
return theater_mapping.dcs_group_id
def configure_tanker_tacan(self, waypoint: MovingPoint) -> None:
if self.flight.unit_type.dcs_unit_type.tacan:
tanker_info = self.mission_data.tankers[-1]
tacan = tanker_info.tacan
tacan_callsign = {
"Texaco": "TEX",
"Arco": "ARC",
"Shell": "SHL",
}.get(tanker_info.callsign)
waypoint.add_task(
ActivateBeaconCommand(
tacan.number,
tacan.band.value,
tacan_callsign,
bearing=True,
unit_id=self.group.units[0].id,
aa=True,
)
)

View File

@ -17,8 +17,12 @@ from game.ato.flightstate import InFlight, WaitingForStart
from game.ato.flightwaypointtype import FlightWaypointType
from game.ato.starttype import StartType
from game.missiongenerator.aircraft.waypoints.cargostop import CargoStopBuilder
from game.missiongenerator.aircraft.waypoints.recoverytanker import (
RecoveryTankerBuilder,
)
from game.missiongenerator.missiondata import MissionData
from game.settings import Settings
from game.unitmap import UnitMap
from game.utils import pairwise
from .baiingress import BaiIngressBuilder
from .landingzone import LandingZoneBuilder
@ -50,6 +54,7 @@ class WaypointGenerator:
time: datetime,
settings: Settings,
mission_data: MissionData,
unit_map: UnitMap,
) -> None:
self.flight = flight
self.group = group
@ -58,6 +63,7 @@ class WaypointGenerator:
self.time = time
self.settings = settings
self.mission_data = mission_data
self.unit_map = unit_map
def create_waypoints(self) -> tuple[timedelta, list[FlightWaypoint]]:
for waypoint in self.flight.points:
@ -135,6 +141,7 @@ class WaypointGenerator:
FlightWaypointType.PICKUP_ZONE: LandingZoneBuilder,
FlightWaypointType.DROPOFF_ZONE: LandingZoneBuilder,
FlightWaypointType.REFUEL: RefuelPointBuilder,
FlightWaypointType.RECOVERY_TANKER: RecoveryTankerBuilder,
FlightWaypointType.CARGO_STOP: CargoStopBuilder,
}
builder = builders.get(waypoint.waypoint_type, DefaultWaypointBuilder)
@ -145,6 +152,7 @@ class WaypointGenerator:
self.mission,
self.elapsed_mission_time,
self.mission_data,
self.unit_map,
)
def _estimate_min_fuel_for(self, waypoints: list[FlightWaypoint]) -> None:

View File

@ -147,7 +147,7 @@ class GroundObjectGenerator:
vehicle_unit.position = unit.position
vehicle_unit.heading = unit.position.heading.degrees
vehicle_group.add_unit(vehicle_unit)
self._register_theater_unit(unit, vehicle_group.units[-1])
self._register_theater_unit(vehicle_group.id, unit, vehicle_group.units[-1])
if vehicle_group is None:
raise RuntimeError(f"Error creating VehicleGroup for {group_name}")
return vehicle_group
@ -180,7 +180,7 @@ class GroundObjectGenerator:
ship_unit.position = unit.position
ship_unit.heading = unit.position.heading.degrees
ship_group.add_unit(ship_unit)
self._register_theater_unit(unit, ship_group.units[-1])
self._register_theater_unit(ship_group.id, unit, ship_group.units[-1])
if ship_group is None:
raise RuntimeError(f"Error creating ShipGroup for {group_name}")
return ship_group
@ -194,7 +194,7 @@ class GroundObjectGenerator:
heading=unit.position.heading.degrees,
dead=not unit.alive,
)
self._register_theater_unit(unit, static_group.units[0])
self._register_theater_unit(static_group.id, unit, static_group.units[0])
@staticmethod
def enable_eplrs(group: VehicleGroup, unit_type: Type[VehicleType]) -> None:
@ -209,10 +209,11 @@ class GroundObjectGenerator:
def _register_theater_unit(
self,
dcs_group_id: int,
theater_unit: TheaterUnit,
dcs_unit: Unit,
) -> None:
self.unit_map.add_theater_unit_mapping(theater_unit, dcs_unit)
self.unit_map.add_theater_unit_mapping(dcs_group_id, theater_unit, dcs_unit)
def add_trigger_zone_for_scenery(self, scenery: SceneryUnit) -> None:
# Align the trigger zones to the faction color on the DCS briefing/F10 map.

View File

@ -1177,6 +1177,8 @@ class NavalControlPoint(ControlPoint, ABC):
if self.is_friendly(for_player):
yield from [
FlightType.AEWC,
FlightType.REFUELING,
# TODO: FlightType.INTERCEPTION
# TODO: Buddy tanking for the A-4?
# TODO: Rescue chopper?
@ -1272,8 +1274,7 @@ class Carrier(NavalControlPoint):
yield from super().mission_types(for_player)
if self.is_friendly(for_player):
yield from [
FlightType.AEWC,
FlightType.REFUELING,
# Nothing yet.
]
def capture(self, game: Game, events: GameUpdateEvents, for_player: bool) -> None:

View File

@ -34,6 +34,7 @@ class FrontLineUnit:
@dataclass(frozen=True)
class TheaterUnitMapping:
dcs_group_id: int
theater_unit: TheaterUnit
dcs_unit: Unit
@ -104,14 +105,16 @@ class UnitMap:
return self.front_line_units.get(name, None)
def add_theater_unit_mapping(
self, theater_unit: TheaterUnit, dcs_unit: Unit
self, dcs_group_id: int, theater_unit: TheaterUnit, dcs_unit: Unit
) -> None:
# Deaths for units at TGOs are recorded in the corresponding GroundUnit within
# the GroundGroup, so we have to match the dcs unit with the liberation unit
name = str(dcs_unit.name)
if name in self.theater_objects:
raise RuntimeError(f"Duplicate TGO unit: {name}")
self.theater_objects[name] = TheaterUnitMapping(theater_unit, dcs_unit)
self.theater_objects[name] = TheaterUnitMapping(
dcs_group_id, theater_unit, dcs_unit
)
def theater_units(self, name: str) -> Optional[TheaterUnitMapping]:
return self.theater_objects.get(name, None)

View File

@ -32,7 +32,7 @@ pluggy==1.0.0
pre-commit==2.19.0
py==1.11.0
pydantic==1.9.1
-e git+https://github.com/pydcs/dcs@f12d70ea844076f95c74ffab92ec3dc9fdee32e4#egg=pydcs
-e git+https://github.com/pydcs/dcs@e755b655b7b28e9af3c1fa42263424b568413c04#egg=pydcs
pyinstaller==5.2
pyinstaller-hooks-contrib==2022.8
pyparsing==3.0.9