diff --git a/changelog.md b/changelog.md index 7833dab5..a8d66706 100644 --- a/changelog.md +++ b/changelog.md @@ -14,6 +14,7 @@ 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]** Refueling flights planned on aircraft carriers will act as a recovery tanker for the carrier. * **[Loadouts]** Adjusted F-15E loadouts. * **[Modding]** Added support for the HMS Ariadne, Achilles, and Castle class. * **[Modding]** Added HMS Invincible to the game data as a helicopter carrier. diff --git a/game/ato/flightplans/flightplanbuildertypes.py b/game/ato/flightplans/flightplanbuildertypes.py index 5d367214..0f4a0de7 100644 --- a/game/ato/flightplans/flightplanbuildertypes.py +++ b/game/ato/flightplans/flightplanbuildertypes.py @@ -3,6 +3,8 @@ from __future__ import annotations from typing import Any, TYPE_CHECKING, Type from game.ato import FlightType +from game.ato.flightplans.shiprecoverytanker import RecoveryTankerFlightPlan +from game.theater.controlpoint import Carrier from .aewc import AewcFlightPlan from .airassault import AirAssaultFlightPlan from .airlift import AirliftFlightPlan @@ -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, Carrier + ): + return RecoveryTankerFlightPlan.builder_type() + if target.is_friendly(flight.squadron.player) or isinstance( + target, FrontLine ): return TheaterRefuelingFlightPlan.builder_type() return PackageRefuelingFlightPlan.builder_type() diff --git a/game/ato/flightplans/shiprecoverytanker.py b/game/ato/flightplans/shiprecoverytanker.py new file mode 100644 index 00000000..8deca407 --- /dev/null +++ b/game/ato/flightplans/shiprecoverytanker.py @@ -0,0 +1,87 @@ +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 +from game.utils import feet + + +@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 timedelta(hours=2) + + @property + def patrol_start_time(self) -> timedelta: + return self.package.time_over_target + + @property + def patrol_end_time(self) -> timedelta: + return self.tot + self.mission_departure_time + + 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.tot + self.mission_departure_time + return None + + +class Builder(IBuilder[RecoveryTankerFlightPlan, RecoveryTankerLayout]): + def layout(self) -> RecoveryTankerLayout: + + # TODO: Propagate the ship position. + ship = self.package.target.position + + builder = WaypointBuilder(self.flight, self.coalition) + + recovery = builder.recovery_tanker(ship) + + tanker_type = self.flight.unit_type + altitude = tanker_type.preferred_patrol_altitude + + return RecoveryTankerLayout( + departure=builder.takeoff(self.flight.departure), + nav_to=builder.nav_path(self.flight.departure.position, ship, altitude), + nav_from=builder.nav_path(ship, self.flight.arrival.position, altitude), + recovery_ship=recovery, + 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()) diff --git a/game/ato/flightplans/waypointbuilder.py b/game/ato/flightplans/waypointbuilder.py index 53fd8a2a..e9cd233e 100644 --- a/game/ato/flightplans/waypointbuilder.py +++ b/game/ato/flightplans/waypointbuilder.py @@ -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: diff --git a/game/ato/flightwaypointtype.py b/game/ato/flightwaypointtype.py index 9fb33196..2cdaa409 100644 --- a/game/ato/flightwaypointtype.py +++ b/game/ato/flightwaypointtype.py @@ -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 diff --git a/game/missiongenerator/aircraft/aircraftbehavior.py b/game/missiongenerator/aircraft/aircraftbehavior.py index 89d3eeeb..fd66672f 100644 --- a/game/missiongenerator/aircraft/aircraftbehavior.py +++ b/game/missiongenerator/aircraft/aircraftbehavior.py @@ -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." diff --git a/game/missiongenerator/aircraft/aircraftgenerator.py b/game/missiongenerator/aircraft/aircraftgenerator.py index 223bc00e..72a9aeb6 100644 --- a/game/missiongenerator/aircraft/aircraftgenerator.py +++ b/game/missiongenerator/aircraft/aircraftgenerator.py @@ -177,6 +177,7 @@ class AircraftGenerator: self.mission_data, dynamic_runways, self.use_client, + self.unit_map, ).configure() ) return group diff --git a/game/missiongenerator/aircraft/flightgroupconfigurator.py b/game/missiongenerator/aircraft/flightgroupconfigurator.py index baeb284d..b3d6e73c 100644 --- a/game/missiongenerator/aircraft/flightgroupconfigurator.py +++ b/game/missiongenerator/aircraft/flightgroupconfigurator.py @@ -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( diff --git a/game/missiongenerator/aircraft/waypoints/pydcswaypointbuilder.py b/game/missiongenerator/aircraft/waypoints/pydcswaypointbuilder.py index 8cc0ed76..786edd95 100644 --- a/game/missiongenerator/aircraft/waypoints/pydcswaypointbuilder.py +++ b/game/missiongenerator/aircraft/waypoints/pydcswaypointbuilder.py @@ -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( diff --git a/game/missiongenerator/aircraft/waypoints/recoverytanker.py b/game/missiongenerator/aircraft/waypoints/recoverytanker.py new file mode 100644 index 00000000..46354d11 --- /dev/null +++ b/game/missiongenerator/aircraft/waypoints/recoverytanker.py @@ -0,0 +1,56 @@ +from dcs.point import MovingPoint +from dcs.task import ActivateBeaconCommand, RecoveryTanker + +from game.ato import FlightType +from game.missiongenerator.missiondata import TankerInfo +from game.utils import feet, knots +from .pydcswaypointbuilder import PydcsWaypointBuilder + + +class RecoveryTankerBuilder(PydcsWaypointBuilder): + def add_tasks(self, waypoint: MovingPoint) -> None: + if 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. + 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, + ) + ) diff --git a/game/missiongenerator/aircraft/waypoints/waypointgenerator.py b/game/missiongenerator/aircraft/waypoints/waypointgenerator.py index da59387a..e638efdb 100644 --- a/game/missiongenerator/aircraft/waypoints/waypointgenerator.py +++ b/game/missiongenerator/aircraft/waypoints/waypointgenerator.py @@ -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: diff --git a/game/missiongenerator/tgogenerator.py b/game/missiongenerator/tgogenerator.py index 8ffc03df..c61ce88a 100644 --- a/game/missiongenerator/tgogenerator.py +++ b/game/missiongenerator/tgogenerator.py @@ -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. diff --git a/game/unitmap.py b/game/unitmap.py index 8298c14b..d9a20d70 100644 --- a/game/unitmap.py +++ b/game/unitmap.py @@ -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) diff --git a/requirements.txt b/requirements.txt index 0aaba6b1..745ed492 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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