diff --git a/game/ato/flightplans/airassault.py b/game/ato/flightplans/airassault.py index d2db440b..a5b2972a 100644 --- a/game/ato/flightplans/airassault.py +++ b/game/ato/flightplans/airassault.py @@ -4,28 +4,38 @@ from dataclasses import dataclass from datetime import timedelta from typing import Iterator, TYPE_CHECKING, Type -from game.ato.flightplans.airlift import AirliftLayout -from game.ato.flightplans.standard import StandardFlightPlan +from game.ato.flightplans.standard import StandardFlightPlan, StandardLayout from game.theater.controlpoint import ControlPointType from game.theater.missiontarget import MissionTarget from game.utils import Distance, feet, meters from .ibuilder import IBuilder +from .planningerror import PlanningError from .waypointbuilder import WaypointBuilder +from ..flightwaypoint import FlightWaypointType if TYPE_CHECKING: from ..flightwaypoint import FlightWaypoint @dataclass(frozen=True) -class AirAssaultLayout(AirliftLayout): +class AirAssaultLayout(StandardLayout): + # The pickup point is optional because we don't always need to load the cargo. When + # departing from a carrier, LHA, or off-map spawn, the cargo is pre-loaded. + pickup: FlightWaypoint | None + nav_to_ingress: list[FlightWaypoint] + ingress: FlightWaypoint + drop_off: FlightWaypoint + # This is an implementation detail used by CTLD. The aircraft will not go to this + # waypoint. It is used by CTLD as the destination for unloaded troops. target: FlightWaypoint + nav_to_home: list[FlightWaypoint] def iter_waypoints(self) -> Iterator[FlightWaypoint]: yield self.departure - yield from self.nav_to_pickup - if self.pickup: + if self.pickup is not None: yield self.pickup - yield from self.nav_to_drop_off + yield from self.nav_to_ingress + yield self.ingress yield self.drop_off yield self.target yield from self.nav_to_home @@ -64,24 +74,32 @@ class AirAssaultFlightPlan(StandardFlightPlan[AirAssaultLayout]): class Builder(IBuilder[AirAssaultFlightPlan, AirAssaultLayout]): def layout(self) -> AirAssaultLayout: + if not self.flight.is_helo: + raise PlanningError("Air assault is only usable by helicopters") + assert self.package.waypoints is not None altitude = feet(1500) if self.flight.is_helo else self.doctrine.ingress_altitude altitude_is_agl = self.flight.is_helo builder = WaypointBuilder(self.flight, self.coalition) - if not self.flight.is_helo or self.flight.departure.cptype in [ + if self.flight.departure.cptype in [ ControlPointType.AIRCRAFT_CARRIER_GROUP, ControlPointType.LHA_GROUP, ControlPointType.OFF_MAP, ]: - # Non-Helo flights or Off_Map will be preloaded + # Off_Map spawns will be preloaded # Carrier operations load the logistics directly from the carrier pickup = None pickup_position = self.flight.departure.position else: + # TODO The calculation of the Pickup LZ is currently randomized. This + # leads to the problem that we can not gurantee that the LZ is clear of + # obstacles. This has to be improved in the future so that the Mission can + # be autoplanned. In the current state the User has to check the created + # Waypoints for the Pickup and Dropoff LZs are free of obstacles. # Create a special pickup zone for Helos from Airbase / FOB - pickup = builder.pickup( + pickup = builder.pickup_zone( MissionTarget( "Pickup Zone", self.flight.departure.position.random_point_within(1200, 600), @@ -90,6 +108,8 @@ class Builder(IBuilder[AirAssaultFlightPlan, AirAssaultLayout]): pickup_position = pickup.position assault_area = builder.assault_area(self.package.target) heading = self.package.target.position.heading_between_point(pickup_position) + + # TODO we can not gurantee a safe LZ for DropOff. See comment above. drop_off_zone = MissionTarget( "Dropoff zone", self.package.target.position.point_from_heading(heading, 1200), @@ -97,21 +117,19 @@ class Builder(IBuilder[AirAssaultFlightPlan, AirAssaultLayout]): return AirAssaultLayout( departure=builder.takeoff(self.flight.departure), - nav_to_pickup=builder.nav_path( - self.flight.departure.position, - pickup_position, - altitude, - altitude_is_agl, - ), pickup=pickup, - nav_to_drop_off=builder.nav_path( + nav_to_ingress=builder.nav_path( pickup_position, - drop_off_zone.position, + self.package.waypoints.ingress, altitude, altitude_is_agl, ), - drop_off=builder.drop_off(drop_off_zone), - stopover=None, + ingress=builder.ingress( + FlightWaypointType.INGRESS_AIR_ASSAULT, + self.package.waypoints.ingress, + self.package.target, + ), + drop_off=builder.dropoff_zone(drop_off_zone), target=assault_area, nav_to_home=builder.nav_path( drop_off_zone.position, diff --git a/game/ato/flightplans/airlift.py b/game/ato/flightplans/airlift.py index 4ff69d05..1f143a35 100644 --- a/game/ato/flightplans/airlift.py +++ b/game/ato/flightplans/airlift.py @@ -19,10 +19,20 @@ if TYPE_CHECKING: @dataclass(frozen=True) class AirliftLayout(StandardLayout): nav_to_pickup: list[FlightWaypoint] + # There will not be a pickup waypoint when the pickup airfield is the departure + # airfield for cargo planes, as the cargo is pre-loaded. Helicopters will still pick + # up the cargo near the airfield. pickup: FlightWaypoint | None + # pickup_zone will be used for player flights to create the CTLD stuff + ctld_pickup_zone: FlightWaypoint | None nav_to_drop_off: list[FlightWaypoint] - drop_off: FlightWaypoint - stopover: FlightWaypoint | None + # There will not be a drop-off waypoint when the drop-off airfield and the arrival + # airfield is the same for a cargo plane, as planes will land to unload and we don't + # want a double landing. Helicopters will still drop their cargo near the airfield + # before landing. + drop_off: FlightWaypoint | None + # drop_off_zone will be used for player flights to create the CTLD stuff + ctld_drop_off_zone: FlightWaypoint | None nav_to_home: list[FlightWaypoint] def iter_waypoints(self) -> Iterator[FlightWaypoint]: @@ -30,10 +40,13 @@ class AirliftLayout(StandardLayout): yield from self.nav_to_pickup if self.pickup is not None: yield self.pickup + if self.ctld_pickup_zone is not None: + yield self.ctld_pickup_zone yield from self.nav_to_drop_off - yield self.drop_off - if self.stopover is not None: - yield self.stopover + if self.drop_off is not None: + yield self.drop_off + if self.ctld_drop_off_zone is not None: + yield self.ctld_drop_off_zone yield from self.nav_to_home yield self.arrival if self.divert is not None: @@ -48,7 +61,11 @@ class AirliftFlightPlan(StandardFlightPlan[AirliftLayout]): @property def tot_waypoint(self) -> FlightWaypoint: - return self.layout.drop_off + # The TOT is the time that the cargo will be dropped off. If the drop-off + # location is the arrival airfield and this is not a helicopter flight, there + # will not be a separate drop-off waypoint; the arrival landing waypoint is the + # drop-off waypoint. + return self.layout.drop_off or self.layout.arrival def tot_for_waypoint(self, waypoint: FlightWaypoint) -> timedelta | None: # TOT planning isn't really useful for transports. They're behind the front @@ -77,31 +94,31 @@ class Builder(IBuilder[AirliftFlightPlan, AirliftLayout]): builder = WaypointBuilder(self.flight, self.coalition) pickup = None - stopover = None + drop_off = None + pickup_zone = None + drop_off_zone = None + + if cargo.origin != self.flight.departure: + pickup = builder.cargo_stop(cargo.origin) + if cargo.next_stop != self.flight.arrival: + drop_off = builder.cargo_stop(cargo.next_stop) + if self.flight.is_helo: - # Create a pickupzone where the cargo will be spawned - pickup_zone = MissionTarget( - "Pickup Zone", cargo.origin.position.random_point_within(1000, 200) + # Create CTLD Zones for Helo flights + pickup_zone = builder.pickup_zone( + MissionTarget( + "Pickup Zone", cargo.origin.position.random_point_within(1000, 200) + ) ) - pickup = builder.pickup(pickup_zone) - # If The cargo is at the departure controlpoint, the pickup waypoint should - # only be created for client flights - pickup.only_for_player = cargo.origin == self.flight.departure - - # Create a dropoff zone where the cargo should be dropped - drop_off_zone = MissionTarget( - "Dropoff zone", - cargo.next_stop.position.random_point_within(1000, 200), + drop_off_zone = builder.dropoff_zone( + MissionTarget( + "Dropoff zone", + cargo.next_stop.position.random_point_within(1000, 200), + ) ) - drop_off = builder.drop_off(drop_off_zone) - - # Add an additional stopover point so that the flight can refuel - stopover = builder.stopover(cargo.next_stop) - else: - # Fixed Wing will get stopover points for pickup and dropoff - if cargo.origin != self.flight.departure: - pickup = builder.stopover(cargo.origin, "PICKUP") - drop_off = builder.stopover(cargo.next_stop, "DROP OFF") + # Show the zone waypoints only to the player + pickup_zone.only_for_player = True + drop_off_zone.only_for_player = True nav_to_pickup = builder.nav_path( self.flight.departure.position, @@ -110,20 +127,11 @@ class Builder(IBuilder[AirliftFlightPlan, AirliftLayout]): altitude_is_agl, ) - if self.flight.client_count > 0: - # Normal Landing Waypoint - arrival = builder.land(self.flight.arrival) - else: - # The AI Needs another Stopover point to actually fly back to the original - # base. Otherwise the Cargo drop will be the new Landing Waypoint and the - # AI will end its mission there instead of flying back. - # https://forum.dcs.world/topic/211775-landing-to-refuel-and-rearm-the-landingrefuar-waypoint/ - arrival = builder.stopover(self.flight.arrival, "LANDING") - return AirliftLayout( departure=builder.takeoff(self.flight.departure), nav_to_pickup=nav_to_pickup, pickup=pickup, + ctld_pickup_zone=pickup_zone, nav_to_drop_off=builder.nav_path( cargo.origin.position, cargo.next_stop.position, @@ -131,14 +139,14 @@ class Builder(IBuilder[AirliftFlightPlan, AirliftLayout]): altitude_is_agl, ), drop_off=drop_off, - stopover=stopover, + ctld_drop_off_zone=drop_off_zone, nav_to_home=builder.nav_path( cargo.origin.position, self.flight.arrival.position, altitude, altitude_is_agl, ), - arrival=arrival, + arrival=builder.land(self.flight.arrival), divert=builder.divert(self.flight.divert), bullseye=builder.bullseye(), ) diff --git a/game/ato/flightplans/waypointbuilder.py b/game/ato/flightplans/waypointbuilder.py index e5ac9367..6d3c6989 100644 --- a/game/ato/flightplans/waypointbuilder.py +++ b/game/ato/flightplans/waypointbuilder.py @@ -304,6 +304,13 @@ class WaypointBuilder: return self._target_area(f"ATTACK {target.name}", target, flyover=True) def assault_area(self, target: MissionTarget) -> FlightWaypoint: + """A destination waypoint used by air-assault ground troops. + + This waypoint is an implementation detail for CTLD and should not be followed by + aircraft. + """ + # TODO: Add a property that can hide this waypoint from the player's flight + # plan. return self._target_area(f"ASSAULT {target.name}", target) @staticmethod @@ -512,58 +519,50 @@ class WaypointBuilder: ) @staticmethod - def stopover(stopover: ControlPoint, name: str = "STOPOVER") -> FlightWaypoint: - """Creates a stopover waypoint. - - Args: - control_point: Pick up location. + def pickup_zone(pick_up: MissionTarget) -> FlightWaypoint: + """Creates a pickup landing zone waypoint + This waypoint is used to generate the Trigger Zone used for AirAssault and + AirLift using the CTLD plugin (see LogisticsGenerator) """ return FlightWaypoint( - name, - FlightWaypointType.STOPOVER, - stopover.position, - meters(0), - "RADIO", - description=f"Stopover at {stopover}", - pretty_name="Stopover location", - control_point=stopover, - ) - - @staticmethod - def pickup(pick_up: MissionTarget) -> FlightWaypoint: - """Creates a cargo pickup waypoint. - - Args: - control_point: Pick up location. - """ - control_point = pick_up if isinstance(pick_up, ControlPoint) else None - return FlightWaypoint( - "PICKUP", - FlightWaypointType.PICKUP, + "PICKUPZONE", + FlightWaypointType.PICKUP_ZONE, pick_up.position, meters(0), "RADIO", description=f"Pick up cargo from {pick_up.name}", - pretty_name="Pick up location", - control_point=control_point, + pretty_name="Pick-up zone", ) @staticmethod - def drop_off(drop_off: MissionTarget) -> FlightWaypoint: - """Creates a cargo drop-off waypoint. - - Args: - control_point: Drop-off location. + def dropoff_zone(drop_off: MissionTarget) -> FlightWaypoint: + """Creates a dropoff landing zone waypoint + This waypoint is used to generate the Trigger Zone used for AirAssault and + AirLift using the CTLD plugin (see LogisticsGenerator) """ - control_point = drop_off if isinstance(drop_off, ControlPoint) else None return FlightWaypoint( - "DROP OFF", - FlightWaypointType.DROP_OFF, + "DROPOFFZONE", + FlightWaypointType.DROPOFF_ZONE, drop_off.position, meters(0), "RADIO", description=f"Drop off cargo at {drop_off.name}", - pretty_name="Drop off location", + pretty_name="Drop-off zone", + ) + + @staticmethod + def cargo_stop(control_point: ControlPoint) -> FlightWaypoint: + """Creates a cargo stop waypoint. + This waypoint is used by AirLift as a landing and stopover waypoint + """ + return FlightWaypoint( + "CARGOSTOP", + FlightWaypointType.CARGO_STOP, + control_point.position, + meters(0), + "RADIO", + description=f"Stop for cargo at {control_point.name}", + pretty_name="Cargo stop", control_point=control_point, ) diff --git a/game/ato/flightstate/inflight.py b/game/ato/flightstate/inflight.py index 24ebc919..c9af70c5 100644 --- a/game/ato/flightstate/inflight.py +++ b/game/ato/flightstate/inflight.py @@ -137,6 +137,7 @@ class InFlight(FlightState, ABC): FlightWaypointType.INGRESS_OCA_RUNWAY, FlightWaypointType.INGRESS_SEAD, FlightWaypointType.INGRESS_STRIKE, + FlightWaypointType.INGRESS_AIR_ASSAULT, } return self.current_waypoint.waypoint_type in contact_types diff --git a/game/ato/flightwaypointtype.py b/game/ato/flightwaypointtype.py index 002c2d0c..9fb33196 100644 --- a/game/ato/flightwaypointtype.py +++ b/game/ato/flightwaypointtype.py @@ -43,8 +43,9 @@ class FlightWaypointType(IntEnum): DIVERT = 23 INGRESS_OCA_RUNWAY = 24 INGRESS_OCA_AIRCRAFT = 25 - PICKUP = 26 - DROP_OFF = 27 + PICKUP_ZONE = 26 # Pickup Zone for cargo or troops + DROPOFF_ZONE = 27 # Dropoff Zone for cargo or troops BULLSEYE = 28 REFUEL = 29 # Should look for nearby tanker to refuel from. - STOPOVER = 30 # Stopover landing point using the LandingReFuAr waypoint type + CARGO_STOP = 30 # Stopover landing point using the LandingReFuAr waypoint type + INGRESS_AIR_ASSAULT = 31 diff --git a/game/commander/objectivefinder.py b/game/commander/objectivefinder.py index 9f5767b6..77520e57 100644 --- a/game/commander/objectivefinder.py +++ b/game/commander/objectivefinder.py @@ -139,14 +139,6 @@ class ObjectiveFinder: """Iterates over all active front lines in the theater.""" yield from self.game.theater.conflicts() - def air_assault_targets(self) -> Iterator[ControlPoint]: - """Iterates over all capturable controlpoints for all active front lines""" - if not self.game.settings.plugin_option("ctld"): - # Air Assault should only be tasked with CTLD enabled - return - for front_line in self.front_lines(): - yield front_line.control_point_hostile_to(self.is_player) - def vulnerable_control_points(self) -> Iterator[ControlPoint]: """Iterates over friendly CPs that are vulnerable to enemy CPs. diff --git a/game/commander/tasks/compound/capturebase.py b/game/commander/tasks/compound/capturebase.py index e8a71846..378cb13e 100644 --- a/game/commander/tasks/compound/capturebase.py +++ b/game/commander/tasks/compound/capturebase.py @@ -7,7 +7,6 @@ from game.commander.tasks.compound.destroyenemygroundunits import ( from game.commander.tasks.compound.reduceenemyfrontlinecapacity import ( ReduceEnemyFrontLineCapacity, ) -from game.commander.tasks.primitive.airassault import PlanAirAssault from game.commander.tasks.primitive.breakthroughattack import BreakthroughAttack from game.commander.theaterstate import TheaterState from game.htn import CompoundTask, Method @@ -19,7 +18,6 @@ class CaptureBase(CompoundTask[TheaterState]): front_line: FrontLine def each_valid_method(self, state: TheaterState) -> Iterator[Method[TheaterState]]: - yield [PlanAirAssault(self.enemy_cp(state))] yield [BreakthroughAttack(self.front_line, state.context.coalition.player)] yield [DestroyEnemyGroundUnits(self.front_line)] if self.worth_destroying_ammo_depots(state): diff --git a/game/commander/theaterstate.py b/game/commander/theaterstate.py index cf6c34a0..3bd05f73 100644 --- a/game/commander/theaterstate.py +++ b/game/commander/theaterstate.py @@ -45,7 +45,6 @@ class TheaterState(WorldState["TheaterState"]): context: PersistentContext barcaps_needed: dict[ControlPoint, int] active_front_lines: list[FrontLine] - air_assault_targets: list[ControlPoint] front_line_stances: dict[FrontLine, Optional[CombatStance]] vulnerable_front_lines: list[FrontLine] aewc_targets: list[MissionTarget] @@ -110,7 +109,6 @@ class TheaterState(WorldState["TheaterState"]): context=self.context, barcaps_needed=dict(self.barcaps_needed), active_front_lines=list(self.active_front_lines), - air_assault_targets=list(self.air_assault_targets), front_line_stances=dict(self.front_line_stances), vulnerable_front_lines=list(self.vulnerable_front_lines), aewc_targets=list(self.aewc_targets), @@ -161,7 +159,6 @@ class TheaterState(WorldState["TheaterState"]): cp: barcap_rounds for cp in finder.vulnerable_control_points() }, active_front_lines=list(finder.front_lines()), - air_assault_targets=list(finder.air_assault_targets()), front_line_stances={f: None for f in finder.front_lines()}, vulnerable_front_lines=list(finder.front_lines()), aewc_targets=[finder.farthest_friendly_control_point()], diff --git a/game/missiongenerator/aircraft/waypoints/cargostop.py b/game/missiongenerator/aircraft/waypoints/cargostop.py index 08a57585..f8aa06ef 100644 --- a/game/missiongenerator/aircraft/waypoints/cargostop.py +++ b/game/missiongenerator/aircraft/waypoints/cargostop.py @@ -1,24 +1,14 @@ -from dcs.point import MovingPoint -from dcs.point import PointAction -from dcs.task import Land +from dcs.point import MovingPoint, PointAction -from game.utils import feet from .pydcswaypointbuilder import PydcsWaypointBuilder class CargoStopBuilder(PydcsWaypointBuilder): def build(self) -> MovingPoint: waypoint = super().build() - # Create a landing task, currently only for Helos! - if self.flight.is_helo: - # Calculate a landing point with a small buffer to prevent AI from landing - # directly at the static ammo depot and exploding - landing_point = waypoint.position.random_point_within(15, 5) - # Use Land Task with 30s duration for helos - waypoint.add_task(Land(landing_point, duration=30)) - else: - # Fixed wing will drop the cargo at the waypoint so we set a lower altitude - waypoint.alt = int(feet(10000).meters) - waypoint.alt_type = "BARO" - waypoint.action = PointAction.FlyOverPoint + waypoint.type = "LandingReFuAr" + waypoint.action = PointAction.LandingReFuAr + waypoint.landing_refuel_rearm_time = 2 # Minutes. + if (control_point := self.waypoint.control_point) is not None: + waypoint.airdrome_id = control_point.airdrome_id_for_landing return waypoint diff --git a/game/missiongenerator/aircraft/waypoints/landingzone.py b/game/missiongenerator/aircraft/waypoints/landingzone.py new file mode 100644 index 00000000..338bb97c --- /dev/null +++ b/game/missiongenerator/aircraft/waypoints/landingzone.py @@ -0,0 +1,17 @@ +from dcs.point import MovingPoint +from dcs.task import Land + + +from .pydcswaypointbuilder import PydcsWaypointBuilder + + +class LandingZoneBuilder(PydcsWaypointBuilder): + def build(self) -> MovingPoint: + waypoint = super().build() + # Create a landing task, currently only for Helos! + # Calculate a landing point with a small buffer to prevent AI from landing + # directly at the static ammo depot and exploding + landing_point = waypoint.position.random_point_within(15, 5) + # Use Land Task with 30s duration for helos + waypoint.add_task(Land(landing_point, duration=30)) + return waypoint diff --git a/game/missiongenerator/aircraft/waypoints/waypointgenerator.py b/game/missiongenerator/aircraft/waypoints/waypointgenerator.py index ffeecf7f..da59387a 100644 --- a/game/missiongenerator/aircraft/waypoints/waypointgenerator.py +++ b/game/missiongenerator/aircraft/waypoints/waypointgenerator.py @@ -16,12 +16,12 @@ from game.ato import Flight, FlightWaypoint from game.ato.flightstate import InFlight, WaitingForStart from game.ato.flightwaypointtype import FlightWaypointType from game.ato.starttype import StartType -from game.missiongenerator.aircraft.waypoints.stopover import StopoverBuilder +from game.missiongenerator.aircraft.waypoints.cargostop import CargoStopBuilder from game.missiongenerator.missiondata import MissionData from game.settings import Settings from game.utils import pairwise from .baiingress import BaiIngressBuilder -from .cargostop import CargoStopBuilder +from .landingzone import LandingZoneBuilder from .casingress import CasIngressBuilder from .deadingress import DeadIngressBuilder from .default import DefaultWaypointBuilder @@ -118,7 +118,6 @@ class WaypointGenerator: def builder_for_waypoint(self, waypoint: FlightWaypoint) -> PydcsWaypointBuilder: builders = { - FlightWaypointType.DROP_OFF: CargoStopBuilder, FlightWaypointType.INGRESS_BAI: BaiIngressBuilder, FlightWaypointType.INGRESS_CAS: CasIngressBuilder, FlightWaypointType.INGRESS_DEAD: DeadIngressBuilder, @@ -133,9 +132,10 @@ class WaypointGenerator: FlightWaypointType.LOITER: HoldPointBuilder, FlightWaypointType.PATROL: RaceTrackEndBuilder, FlightWaypointType.PATROL_TRACK: RaceTrackBuilder, - FlightWaypointType.PICKUP: CargoStopBuilder, + FlightWaypointType.PICKUP_ZONE: LandingZoneBuilder, + FlightWaypointType.DROPOFF_ZONE: LandingZoneBuilder, FlightWaypointType.REFUEL: RefuelPointBuilder, - FlightWaypointType.STOPOVER: StopoverBuilder, + FlightWaypointType.CARGO_STOP: CargoStopBuilder, } builder = builders.get(waypoint.waypoint_type, DefaultWaypointBuilder) return builder( diff --git a/game/missiongenerator/logisticsgenerator.py b/game/missiongenerator/logisticsgenerator.py index e6aa7f38..77a0ab7f 100644 --- a/game/missiongenerator/logisticsgenerator.py +++ b/game/missiongenerator/logisticsgenerator.py @@ -57,8 +57,8 @@ class LogisticsGenerator: if ( waypoint.waypoint_type not in [ - FlightWaypointType.PICKUP, - FlightWaypointType.DROP_OFF, + FlightWaypointType.PICKUP_ZONE, + FlightWaypointType.DROPOFF_ZONE, ] or waypoint.only_for_player and not self.flight.client_count @@ -69,7 +69,7 @@ class LogisticsGenerator: self.mission.triggers.add_triggerzone( waypoint.position, ZONE_RADIUS, False, zone_name ) - if waypoint.waypoint_type == FlightWaypointType.PICKUP: + if waypoint.waypoint_type == FlightWaypointType.PICKUP_ZONE: pickup_point = waypoint.position logistics_info.pickup_zone = zone_name else: