From 9303e1cb9e123bf11e7ffe1f42673f4d7833cba4 Mon Sep 17 00:00:00 2001 From: Raffson Date: Mon, 4 Mar 2024 00:00:08 +0100 Subject: [PATCH] Configurable cruise & combat altitude + randomized offsets (phase 1) --- changelog.md | 2 + game/ato/flight.py | 6 ++ game/ato/flightplans/aewc.py | 7 +- game/ato/flightplans/airassault.py | 11 +-- game/ato/flightplans/airlift.py | 9 +- game/ato/flightplans/barcap.py | 12 +-- game/ato/flightplans/cas.py | 8 +- game/ato/flightplans/escort.py | 11 +-- game/ato/flightplans/ferry.py | 4 +- game/ato/flightplans/formationattack.py | 10 +- game/ato/flightplans/ibuilder.py | 1 + game/ato/flightplans/packagerefueling.py | 8 +- game/ato/flightplans/rtb.py | 4 +- game/ato/flightplans/sweep.py | 10 +- game/ato/flightplans/tarcap.py | 12 +-- game/ato/flightplans/theaterrefueling.py | 8 +- game/ato/flightplans/waypointbuilder.py | 92 ++++++++++--------- game/data/doctrine.py | 32 ++++--- game/dcs/aircrafttype.py | 89 +++++++++++++----- game/migrator.py | 2 +- .../aircraft/waypoints/landingpoint.py | 4 +- game/settings/settings.py | 12 ++- .../flight/waypoints/QFlightWaypointList.py | 2 +- 23 files changed, 190 insertions(+), 166 deletions(-) diff --git a/changelog.md b/changelog.md index 6a952bda..ea58f8b6 100644 --- a/changelog.md +++ b/changelog.md @@ -44,6 +44,8 @@ * **[Options]** Add option (so it can be disabled when fixed in DCS) to force air-starts (except for the slots that work) at Nevatim due to https://forum.dcs.world/topic/335545-29-nevatim-ramp-starts-still-bugged/ * **[Cheat]** Add cheat option to manually manage REDFOR's TGOs * **[UX]** Buy/Replace TGOs for free before the campaign has started +* **[Data]** Ability to define "cruise" & "combat" altitudes for airplanes +* **[Options]** Option to randomize altitudes for flights with airplanes ## Fixes * **[Mission Generation]** Anti-ship strikes should use "group attack" in their attack-task diff --git a/game/ato/flight.py b/game/ato/flight.py index ef94b028..4509cce6 100644 --- a/game/ato/flight.py +++ b/game/ato/flight.py @@ -1,5 +1,6 @@ from __future__ import annotations +import random import uuid from collections.abc import Iterator from datetime import datetime, timedelta @@ -123,6 +124,11 @@ class Flight( ) ) + # altitude offset for planes + offset_factor = self.coalition.game.settings.max_plane_altitude_offset + offset_factor = random.randint(0, offset_factor) + self.plane_altitude_offset = 1000 * offset_factor * random.choice([-1, 1]) + @property def available_callsigns(self) -> List[str]: callsigns = set() diff --git a/game/ato/flightplans/aewc.py b/game/ato/flightplans/aewc.py index bdcdf59b..59c3445a 100644 --- a/game/ato/flightplans/aewc.py +++ b/game/ato/flightplans/aewc.py @@ -6,7 +6,7 @@ from typing import Type from game.ato.flightplans.ibuilder import IBuilder from game.ato.flightplans.patrolling import PatrollingFlightPlan, PatrollingLayout from game.ato.flightplans.waypointbuilder import WaypointBuilder -from game.utils import Distance, Heading, Speed, feet, knots, meters, nautical_miles +from game.utils import Distance, Heading, Speed, knots, meters, nautical_miles class AewcFlightPlan(PatrollingFlightPlan[PatrollingLayout]): @@ -70,10 +70,7 @@ class Builder(IBuilder[AewcFlightPlan, PatrollingLayout]): builder = WaypointBuilder(self.flight) - if self.flight.unit_type.patrol_altitude is not None: - altitude = self.flight.unit_type.patrol_altitude - else: - altitude = feet(25000) + altitude = builder.get_patrol_altitude racetrack = builder.race_track(racetrack_start, racetrack_end, altitude) diff --git a/game/ato/flightplans/airassault.py b/game/ato/flightplans/airassault.py index f03943a8..46bc1542 100644 --- a/game/ato/flightplans/airassault.py +++ b/game/ato/flightplans/airassault.py @@ -112,12 +112,11 @@ class Builder(FormationAttackBuilder[AirAssaultFlightPlan, AirAssaultLayout]): ) assert self.package.waypoints is not None - heli_alt = feet(self.coalition.game.settings.heli_cruise_alt_agl) - altitude = heli_alt if self.flight.is_helo else self.doctrine.ingress_altitude - altitude_is_agl = self.flight.is_helo - builder = WaypointBuilder(self.flight) + altitude = builder.get_cruise_altitude + altitude_is_agl = self.flight.is_helo + if self.flight.is_hercules or self.flight.departure.cptype in [ ControlPointType.AIRCRAFT_CARRIER_GROUP, ControlPointType.LHA_GROUP, @@ -134,7 +133,7 @@ class Builder(FormationAttackBuilder[AirAssaultFlightPlan, AirAssaultLayout]): self._generate_ctld_pickup(), ) ) - pickup.alt = heli_alt + pickup.alt = altitude pickup_position = pickup.position ingress = builder.ingress( @@ -160,8 +159,6 @@ class Builder(FormationAttackBuilder[AirAssaultFlightPlan, AirAssaultLayout]): drop_pos = tgt.position.point_from_heading(heading, 1200) drop_off_zone = MissionTarget("Dropoff zone", drop_pos) dz = builder.dropoff_zone(drop_off_zone) if self.flight.is_helo else None - if dz: - dz.alt = heli_alt return AirAssaultLayout( departure=builder.takeoff(self.flight.departure), diff --git a/game/ato/flightplans/airlift.py b/game/ato/flightplans/airlift.py index 0e673b80..a6fc968a 100644 --- a/game/ato/flightplans/airlift.py +++ b/game/ato/flightplans/airlift.py @@ -8,7 +8,7 @@ from typing import Optional from typing import TYPE_CHECKING, Type from game.theater.missiontarget import MissionTarget -from game.utils import feet, Distance +from game.utils import Distance from ._common_ctld import generate_random_ctld_point from .ibuilder import IBuilder from .planningerror import PlanningError @@ -133,12 +133,11 @@ class Builder(IBuilder[AirliftFlightPlan, AirliftLayout]): "Cannot plan transport mission for flight with no cargo." ) - heli_alt = feet(self.coalition.game.settings.heli_cruise_alt_agl) - altitude = heli_alt if self.flight.is_helo else self.doctrine.ingress_altitude - altitude_is_agl = self.flight.is_helo - builder = WaypointBuilder(self.flight) + altitude = builder.get_cruise_altitude + altitude_is_agl = self.flight.is_helo + pickup_ascent = None pickup_descent = None pickup = None diff --git a/game/ato/flightplans/barcap.py b/game/ato/flightplans/barcap.py index 1f2bd973..4815a88f 100644 --- a/game/ato/flightplans/barcap.py +++ b/game/ato/flightplans/barcap.py @@ -1,11 +1,10 @@ from __future__ import annotations -import random from datetime import timedelta from typing import Type from game.theater import FrontLine -from game.utils import Distance, Speed, feet +from game.utils import Distance, Speed from .capbuilder import CapBuilder from .invalidobjectivelocation import InvalidObjectiveLocation from .patrolling import PatrollingFlightPlan, PatrollingLayout @@ -41,14 +40,9 @@ class Builder(CapBuilder[BarCapFlightPlan, PatrollingLayout]): start_pos, end_pos = self.cap_racetrack_for_objective(location, barcap=True) - preferred_alt = self.flight.unit_type.preferred_patrol_altitude - randomized_alt = preferred_alt + feet(random.randint(-2, 1) * 1000) - patrol_alt = max( - self.doctrine.min_patrol_altitude, - min(self.doctrine.max_patrol_altitude, randomized_alt), - ) - builder = WaypointBuilder(self.flight) + patrol_alt = builder.get_patrol_altitude + start, end = builder.race_track(start_pos, end_pos, patrol_alt) return PatrollingLayout( diff --git a/game/ato/flightplans/cas.py b/game/ato/flightplans/cas.py index 217264bc..26f7e7c6 100644 --- a/game/ato/flightplans/cas.py +++ b/game/ato/flightplans/cas.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Type from game.theater import FrontLine from game.utils import Distance, Speed, kph, dcs_to_shapely_point -from game.utils import feet, nautical_miles +from game.utils import nautical_miles from .ibuilder import IBuilder from .invalidobjectivelocation import InvalidObjectiveLocation from .patrolling import PatrollingFlightPlan, PatrollingLayout @@ -105,11 +105,7 @@ class Builder(IBuilder[CasFlightPlan, CasLayout]): builder = WaypointBuilder(self.flight) is_helo = self.flight.unit_type.dcs_unit_type.helicopter - ingress_egress_altitude = ( - self.doctrine.ingress_altitude - if not is_helo - else feet(self.coalition.game.settings.heli_combat_alt_agl) - ) + ingress_egress_altitude = builder.get_combat_altitude use_agl_patrol_altitude = is_helo ip_solver = IpSolver( diff --git a/game/ato/flightplans/escort.py b/game/ato/flightplans/escort.py index ee73d5d8..53385f24 100644 --- a/game/ato/flightplans/escort.py +++ b/game/ato/flightplans/escort.py @@ -11,7 +11,6 @@ from .formationattack import ( ) from .waypointbuilder import WaypointBuilder from .. import FlightType -from ...utils import feet class EscortFlightPlan(FormationAttackFlightPlan): @@ -43,12 +42,9 @@ class Builder(FormationAttackBuilder[EscortFlightPlan, FormationAttackLayout]): split = builder.split(self._get_split()) - ingress_alt = self.doctrine.ingress_altitude is_helo = builder.flight.is_helo - heli_alt = feet(self.coalition.game.settings.heli_combat_alt_agl) initial = builder.escort_hold( target.position if is_helo else self.package.waypoints.initial, - min(heli_alt, ingress_alt) if is_helo else ingress_alt, ) pf = self.package.primary_flight @@ -69,9 +65,6 @@ class Builder(FormationAttackBuilder[EscortFlightPlan, FormationAttackLayout]): if layout.drop_off: initial = builder.escort_hold( layout.drop_off.position, - min(feet(200), ingress_alt) - if builder.flight.is_helo - else ingress_alt, ) refuel = self._build_refuel(builder) @@ -80,13 +73,13 @@ class Builder(FormationAttackBuilder[EscortFlightPlan, FormationAttackLayout]): nav_to = builder.nav_path( hold.position if hold else departure.position, join.position, - self.doctrine.ingress_altitude, + builder.get_cruise_altitude, ) nav_from = builder.nav_path( refuel.position if refuel else split.position, self.flight.arrival.position, - self.doctrine.ingress_altitude, + builder.get_cruise_altitude, ) return FormationAttackLayout( diff --git a/game/ato/flightplans/ferry.py b/game/ato/flightplans/ferry.py index eebb0e05..74abee01 100644 --- a/game/ato/flightplans/ferry.py +++ b/game/ato/flightplans/ferry.py @@ -61,14 +61,14 @@ class Builder(IBuilder[FerryFlightPlan, FerryLayout]): f"{self.flight.departure}" ) + builder = WaypointBuilder(self.flight) altitude_is_agl = self.flight.is_helo altitude = ( feet(self.coalition.game.settings.heli_cruise_alt_agl) if altitude_is_agl - else self.flight.unit_type.preferred_patrol_altitude + else builder.get_patrol_altitude ) - builder = WaypointBuilder(self.flight) return FerryLayout( departure=builder.takeoff(self.flight.departure), nav_to=builder.nav_path( diff --git a/game/ato/flightplans/formationattack.py b/game/ato/flightplans/formationattack.py index 3faae11c..c751a038 100644 --- a/game/ato/flightplans/formationattack.py +++ b/game/ato/flightplans/formationattack.py @@ -11,7 +11,7 @@ from dcs import Point from game.flightplan import HoldZoneGeometry from game.theater import MissionTarget -from game.utils import Speed, meters, nautical_miles, feet +from game.utils import Speed, meters, nautical_miles from .flightplan import FlightPlan from .formation import FormationFlightPlan, FormationLayout from .ibuilder import IBuilder @@ -210,14 +210,10 @@ class FormationAttackBuilder(IBuilder[FlightPlanT, LayoutT], ABC): if self.flight.flight_type == FlightType.STRIKE: hdg = self.package.target.position.heading_between_point(ingress.position) pos = ingress.position.point_from_heading(hdg, nautical_miles(10).meters) - lineup = builder.nav(pos, self.flight.coalition.doctrine.ingress_altitude) + lineup = builder.nav(pos, builder.get_combat_altitude) is_helo = self.flight.is_helo - ingress_egress_altitude = ( - self.doctrine.ingress_altitude - if not is_helo - else feet(self.coalition.game.settings.heli_combat_alt_agl) - ) + ingress_egress_altitude = builder.get_combat_altitude use_agl_ingress_egress = is_helo return FormationAttackLayout( diff --git a/game/ato/flightplans/ibuilder.py b/game/ato/flightplans/ibuilder.py index a91cd6f3..fbdd1216 100644 --- a/game/ato/flightplans/ibuilder.py +++ b/game/ato/flightplans/ibuilder.py @@ -25,6 +25,7 @@ class IBuilder(ABC, Generic[FlightPlanT, LayoutT]): def __init__(self, flight: Flight) -> None: self.flight = flight self._flight_plan: FlightPlanT | None = None + self.settings = self.flight.coalition.game.settings def get_or_build(self) -> FlightPlanT: if self._flight_plan is None: diff --git a/game/ato/flightplans/packagerefueling.py b/game/ato/flightplans/packagerefueling.py index a28a907a..4b0a985f 100644 --- a/game/ato/flightplans/packagerefueling.py +++ b/game/ato/flightplans/packagerefueling.py @@ -5,7 +5,7 @@ from typing import Type from dcs import Point -from game.utils import Distance, Heading, feet, meters +from game.utils import Distance, Heading, meters from .ibuilder import IBuilder from .patrolling import PatrollingLayout from .refuelingflightplan import RefuelingFlightPlan @@ -98,11 +98,7 @@ class Builder(IBuilder[PackageRefuelingFlightPlan, PatrollingLayout]): builder = WaypointBuilder(self.flight) - tanker_type = self.flight.unit_type - if tanker_type.patrol_altitude is not None: - altitude = tanker_type.patrol_altitude - else: - altitude = feet(21000) + altitude = builder.get_patrol_altitude racetrack = builder.race_track(racetrack_start, racetrack_end, altitude) diff --git a/game/ato/flightplans/rtb.py b/game/ato/flightplans/rtb.py index 7b430f8a..59e3c267 100644 --- a/game/ato/flightplans/rtb.py +++ b/game/ato/flightplans/rtb.py @@ -66,13 +66,13 @@ class Builder(IBuilder[RtbFlightPlan, RtbLayout]): current_position = self.flight.state.estimate_position() current_altitude, altitude_reference = self.flight.state.estimate_altitude() + builder = WaypointBuilder(self.flight) altitude_is_agl = self.flight.is_helo altitude = ( feet(self.coalition.game.settings.heli_cruise_alt_agl) if altitude_is_agl - else self.flight.unit_type.preferred_patrol_altitude + else builder.get_patrol_altitude ) - builder = WaypointBuilder(self.flight) abort_point = builder.nav( current_position, current_altitude, altitude_reference == "RADIO" ) diff --git a/game/ato/flightplans/sweep.py b/game/ato/flightplans/sweep.py index 4f8b5cfc..19e1c3ab 100644 --- a/game/ato/flightplans/sweep.py +++ b/game/ato/flightplans/sweep.py @@ -105,20 +105,20 @@ class Builder(IBuilder[SweepFlightPlan, SweepLayout]): ) builder = WaypointBuilder(self.flight) - start, end = builder.sweep(start_pos, target, self.doctrine.ingress_altitude) + altitude = builder.get_patrol_altitude + + start, end = builder.sweep(start_pos, target, altitude) hold = builder.hold(self._hold_point()) return SweepLayout( departure=builder.takeoff(self.flight.departure), hold=hold, - nav_to=builder.nav_path( - hold.position, start.position, self.doctrine.ingress_altitude - ), + nav_to=builder.nav_path(hold.position, start.position, altitude), nav_from=builder.nav_path( end.position, self.flight.arrival.position, - self.doctrine.ingress_altitude, + altitude, ), sweep_start=start, sweep_end=end, diff --git a/game/ato/flightplans/tarcap.py b/game/ato/flightplans/tarcap.py index d7805ac3..b3c8fdb8 100644 --- a/game/ato/flightplans/tarcap.py +++ b/game/ato/flightplans/tarcap.py @@ -1,12 +1,11 @@ from __future__ import annotations -import random from collections.abc import Iterator from dataclasses import dataclass from datetime import datetime, timedelta from typing import TYPE_CHECKING, Type -from game.utils import Distance, Speed, feet +from game.utils import Distance, Speed from .capbuilder import CapBuilder from .patrolling import PatrollingFlightPlan, PatrollingLayout from .waypointbuilder import WaypointBuilder @@ -96,14 +95,9 @@ class Builder(CapBuilder[TarCapFlightPlan, TarCapLayout]): def layout(self) -> TarCapLayout: location = self.package.target - preferred_alt = self.flight.unit_type.preferred_patrol_altitude - randomized_alt = preferred_alt + feet(random.randint(-2, 1) * 1000) - patrol_alt = max( - self.doctrine.min_patrol_altitude, - min(self.doctrine.max_patrol_altitude, randomized_alt), - ) - builder = WaypointBuilder(self.flight) + patrol_alt = builder.get_patrol_altitude + orbit0p, orbit1p = self.cap_racetrack_for_objective(location, barcap=False) start, end = builder.race_track(orbit0p, orbit1p, patrol_alt) diff --git a/game/ato/flightplans/theaterrefueling.py b/game/ato/flightplans/theaterrefueling.py index 76d34ab0..67efb073 100644 --- a/game/ato/flightplans/theaterrefueling.py +++ b/game/ato/flightplans/theaterrefueling.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import timedelta from typing import Type -from game.utils import Heading, feet, meters, nautical_miles +from game.utils import Heading, meters, nautical_miles from .ibuilder import IBuilder from .patrolling import PatrollingLayout from .refuelingflightplan import RefuelingFlightPlan @@ -58,11 +58,7 @@ class Builder(IBuilder[TheaterRefuelingFlightPlan, PatrollingLayout]): builder = WaypointBuilder(self.flight) - tanker_type = self.flight.unit_type - if tanker_type.patrol_altitude is not None: - altitude = tanker_type.patrol_altitude - else: - altitude = feet(21000) + altitude = builder.get_patrol_altitude racetrack = builder.race_track(racetrack_start, racetrack_end, altitude) diff --git a/game/ato/flightplans/waypointbuilder.py b/game/ato/flightplans/waypointbuilder.py index 3ed23cb3..8cef8a7d 100644 --- a/game/ato/flightplans/waypointbuilder.py +++ b/game/ato/flightplans/waypointbuilder.py @@ -51,11 +51,34 @@ class WaypointBuilder: self.navmesh = coalition.nav_mesh self.targets = targets self._bullseye = coalition.bullseye + self.settings = self.flight.coalition.game.settings @property def is_helo(self) -> bool: return self.flight.is_helo + @property + def get_patrol_altitude(self) -> Distance: + return self.get_altitude(self.flight.unit_type.preferred_patrol_altitude) + + @property + def get_cruise_altitude(self) -> Distance: + return self.get_altitude(self.flight.unit_type.preferred_cruise_altitude) + + @property + def get_combat_altitude(self) -> Distance: + return self.get_altitude(self.flight.unit_type.preferred_combat_altitude) + + def get_altitude(self, alt: Distance) -> Distance: + randomized_alt = feet(round(alt.feet + self.flight.plane_altitude_offset)) + altitude = max( + self.doctrine.min_combat_altitude, + min(self.doctrine.max_combat_altitude, randomized_alt), + ) + return ( + feet(self.settings.heli_combat_alt_agl) if self.flight.is_helo else altitude + ) + def takeoff(self, departure: ControlPoint) -> FlightWaypoint: """Create takeoff waypoint for the given arrival airfield or carrier. @@ -72,9 +95,7 @@ class WaypointBuilder: "NAV", FlightWaypointType.NAV, position, - feet(self.flight.coalition.game.settings.heli_cruise_alt_agl) - if self.is_helo - else self.doctrine.rendezvous_altitude, + self.get_cruise_altitude, description="Enter theater", pretty_name="Enter theater", ) @@ -101,9 +122,7 @@ class WaypointBuilder: "NAV", FlightWaypointType.NAV, position, - feet(self.flight.coalition.game.settings.heli_cruise_alt_agl) - if self.is_helo - else self.doctrine.rendezvous_altitude, + self.get_cruise_altitude, description="Exit theater", pretty_name="Exit theater", ) @@ -129,14 +148,10 @@ class WaypointBuilder: return None position = divert.position - altitude_type: AltitudeReference + altitude_type: AltitudeReference = "BARO" if isinstance(divert, OffMapSpawn): - altitude = ( - feet(self.flight.coalition.game.settings.heli_cruise_alt_agl) - if self.is_helo - else self.doctrine.rendezvous_altitude - ) - altitude_type = "BARO" + altitude = self.get_cruise_altitude + altitude_type = "RADIO" if self.is_helo else altitude_type else: altitude = meters(0) altitude_type = "RADIO" @@ -173,9 +188,8 @@ class WaypointBuilder: "HOLD", FlightWaypointType.LOITER, position, - feet(self.flight.coalition.game.settings.heli_cruise_alt_agl) - if self.is_helo - else self.doctrine.ingress_altitude, + # TODO: dedicated altitude setting for holding + self.get_cruise_altitude if self.is_helo else self.get_combat_altitude, alt_type, description="Wait until push time", pretty_name="Hold", @@ -190,9 +204,7 @@ class WaypointBuilder: "JOIN", FlightWaypointType.JOIN, position, - feet(self.flight.coalition.game.settings.heli_cruise_alt_agl) - if self.is_helo - else self.doctrine.ingress_altitude, + self.get_cruise_altitude, alt_type, description="Rendezvous with package", pretty_name="Join", @@ -207,9 +219,7 @@ class WaypointBuilder: "REFUEL", FlightWaypointType.REFUEL, position, - feet(self.flight.coalition.game.settings.heli_cruise_alt_agl) - if self.is_helo - else self.doctrine.ingress_altitude, + self.get_cruise_altitude, alt_type, description="Refuel from tanker", pretty_name="Refuel", @@ -224,9 +234,7 @@ class WaypointBuilder: "SPLIT", FlightWaypointType.SPLIT, position, - feet(self.flight.coalition.game.settings.heli_combat_alt_agl) - if self.is_helo - else self.doctrine.ingress_altitude, + self.get_combat_altitude, alt_type, description="Depart from package", pretty_name="Split", @@ -238,7 +246,7 @@ class WaypointBuilder: position: Point, objective: MissionTarget, ) -> FlightWaypoint: - alt = self.doctrine.ingress_altitude + alt = self.get_combat_altitude alt_type: AltitudeReference = "BARO" if self.is_helo or self.flight.is_hercules: alt_type = "RADIO" @@ -272,9 +280,7 @@ class WaypointBuilder: "EGRESS", FlightWaypointType.EGRESS, position, - feet(self.flight.coalition.game.settings.heli_combat_alt_agl) - if self.is_helo - else self.doctrine.ingress_altitude, + self.get_combat_altitude, alt_type, description=f"EGRESS from {target.name}", pretty_name=f"EGRESS from {target.name}", @@ -315,7 +321,7 @@ class WaypointBuilder: return self._target_area( f"SEAD on {target.name}", target, - altitude=self.doctrine.ingress_altitude, + altitude=self.get_combat_altitude, alt_type="BARO", ) @@ -453,9 +459,7 @@ class WaypointBuilder: "SEAD Search", FlightWaypointType.NAV, hold, - feet(self.flight.coalition.game.settings.heli_combat_alt_agl) - if self.is_helo - else self.doctrine.ingress_altitude, + self.get_combat_altitude, alt_type="BARO", description="Anchor and search from this point", pretty_name="SEAD Search", @@ -468,10 +472,8 @@ class WaypointBuilder: "SEAD Sweep", FlightWaypointType.NAV, hold, - feet(self.flight.coalition.game.settings.heli_combat_alt_agl) - if self.is_helo - else self.doctrine.ingress_altitude, - alt_type="BARO", + self.get_combat_altitude, + alt_type="BARO", # SEAD Sweep shouldn't be used for helicopters description="Anchor and search from this point", pretty_name="SEAD Sweep", ) @@ -499,13 +501,15 @@ class WaypointBuilder: ) return hold - def escort_hold(self, start: Point, altitude: Distance) -> FlightWaypoint: + def escort_hold(self, start: Point) -> FlightWaypoint: """Creates custom waypoint for escort flights that need to hold. Args: start: Position of the waypoint. altitude: Altitude of the holding pattern. """ + altitude = self.get_combat_altitude + alt_type: Literal["BARO", "RADIO"] = "BARO" if self.is_helo: alt_type = "RADIO" @@ -592,9 +596,7 @@ class WaypointBuilder: "TARGET", FlightWaypointType.TARGET_GROUP_LOC, target.position, - feet(self.flight.coalition.game.settings.heli_combat_alt_agl) - if self.is_helo - else self.doctrine.ingress_altitude, + self.get_combat_altitude, alt_type, description="Escort the package", pretty_name="Target area", @@ -616,17 +618,19 @@ class WaypointBuilder: pretty_name="Pick-up zone", ) - @staticmethod - def dropoff_zone(drop_off: MissionTarget) -> FlightWaypoint: + def dropoff_zone(self, 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) """ + heli_alt = feet(self.flight.coalition.game.settings.heli_cruise_alt_agl) + altitude = heli_alt if self.flight.is_helo else meters(0) + return FlightWaypoint( "DROPOFFZONE", FlightWaypointType.DROPOFF_ZONE, drop_off.position, - meters(0), + altitude, "RADIO", description=f"Drop off cargo at {drop_off.name}", pretty_name="Drop-off zone", diff --git a/game/data/doctrine.py b/game/data/doctrine.py index 19cc410a..16227c29 100644 --- a/game/data/doctrine.py +++ b/game/data/doctrine.py @@ -26,8 +26,6 @@ class Doctrine: strike: bool antiship: bool - rendezvous_altitude: Distance - #: The minimum distance between the departure airfield and the hold point. hold_distance: Distance @@ -46,11 +44,14 @@ class Doctrine: #: target. min_ingress_distance: Distance - ingress_altitude: Distance - min_patrol_altitude: Distance max_patrol_altitude: Distance - pattern_altitude: Distance + + min_cruise_altitude: Distance + max_cruise_altitude: Distance + + min_combat_altitude: Distance + max_combat_altitude: Distance #: The duration that CAP flights will remain on-station. cap_duration: timedelta @@ -97,16 +98,17 @@ MODERN_DOCTRINE = Doctrine( sead=True, strike=True, antiship=True, - rendezvous_altitude=feet(25000), hold_distance=nautical_miles(25), push_distance=nautical_miles(20), join_distance=nautical_miles(20), max_ingress_distance=nautical_miles(45), min_ingress_distance=nautical_miles(10), - ingress_altitude=feet(20000), min_patrol_altitude=feet(15000), max_patrol_altitude=feet(33000), - pattern_altitude=feet(5000), + min_cruise_altitude=feet(20000), + max_cruise_altitude=feet(40000), + min_combat_altitude=feet(15000), + max_combat_altitude=feet(35000), cap_duration=timedelta(minutes=30), cap_min_track_length=nautical_miles(15), cap_max_track_length=nautical_miles(40), @@ -140,16 +142,17 @@ COLDWAR_DOCTRINE = Doctrine( sead=True, strike=True, antiship=True, - rendezvous_altitude=feet(22000), hold_distance=nautical_miles(15), push_distance=nautical_miles(10), join_distance=nautical_miles(10), max_ingress_distance=nautical_miles(30), min_ingress_distance=nautical_miles(10), - ingress_altitude=feet(18000), min_patrol_altitude=feet(10000), max_patrol_altitude=feet(24000), - pattern_altitude=feet(5000), + min_cruise_altitude=feet(20000), + max_cruise_altitude=feet(30000), + min_combat_altitude=feet(5000), + max_combat_altitude=feet(25000), cap_duration=timedelta(minutes=30), cap_min_track_length=nautical_miles(12), cap_max_track_length=nautical_miles(24), @@ -186,13 +189,14 @@ WWII_DOCTRINE = Doctrine( hold_distance=nautical_miles(10), push_distance=nautical_miles(5), join_distance=nautical_miles(5), - rendezvous_altitude=feet(10000), max_ingress_distance=nautical_miles(7), min_ingress_distance=nautical_miles(5), - ingress_altitude=feet(8000), min_patrol_altitude=feet(4000), max_patrol_altitude=feet(15000), - pattern_altitude=feet(5000), + min_cruise_altitude=feet(5000), + max_cruise_altitude=feet(30000), + min_combat_altitude=feet(3000), + max_combat_altitude=feet(10000), cap_duration=timedelta(minutes=30), cap_min_track_length=nautical_miles(8), cap_max_track_length=nautical_miles(18), diff --git a/game/dcs/aircrafttype.py b/game/dcs/aircrafttype.py index d9250cac..b3e1f32b 100644 --- a/game/dcs/aircrafttype.py +++ b/game/dcs/aircrafttype.py @@ -132,6 +132,21 @@ class PatrolConfig: ) +@dataclass(frozen=True) +class AltitudesConfig: + cruise: Optional[Distance] + combat: Optional[Distance] + + @classmethod + def from_data(cls, data: dict[str, Any]) -> AltitudesConfig: + cruise = data.get("cruise", None) + combat = data.get("combat", None) + return AltitudesConfig( + feet(cruise) if cruise is not None else None, + feet(combat) if combat is not None else None, + ) + + @dataclass(frozen=True) class FuelConsumption: #: The estimated taxi fuel requirement, in pounds. @@ -182,6 +197,9 @@ class AircraftType(UnitType[Type[FlyingType]]): patrol_altitude: Optional[Distance] patrol_speed: Optional[Speed] + cruise_altitude: Optional[Distance] + combat_altitude: Optional[Distance] + #: The maximum range between the origin airfield and the target for which the auto- #: planner will consider this aircraft usable for a mission. max_mission_range: Distance @@ -245,33 +263,13 @@ class AircraftType(UnitType[Type[FlyingType]]): def max_speed(self) -> Speed: return kph(self.dcs_unit_type.max_speed) - @property + @cached_property def preferred_patrol_altitude(self) -> Distance: - if self.patrol_altitude is not None: + if self.patrol_altitude: return self.patrol_altitude else: - # Estimate based on max speed. - # Aircaft with max speed 600 kph will prefer patrol at 10 000 ft - # Aircraft with max speed 2800 kph will prefer pratrol at 33 000 ft - altitude_for_lowest_speed = feet(10 * 1000) - altitude_for_highest_speed = feet(33 * 1000) - lowest_speed = kph(600) - highest_speed = kph(2800) - factor = (self.max_speed - lowest_speed).kph / ( - highest_speed - lowest_speed - ).kph - altitude = ( - altitude_for_lowest_speed - + (altitude_for_highest_speed - altitude_for_lowest_speed) * factor - ) - logging.debug( - f"Preferred patrol altitude for {self.dcs_unit_type.id}: {altitude.feet}" - ) - rounded_altitude = feet(round(1000 * round(altitude.feet / 1000))) - return max( - altitude_for_lowest_speed, - min(altitude_for_highest_speed, rounded_altitude), - ) + # TODO: somehow make the upper and lower limit configurable + return self.preferred_altitude(10, 33, "patrol") def preferred_patrol_speed(self, altitude: Distance) -> Speed: """Preferred true airspeed when patrolling""" @@ -309,6 +307,46 @@ class AircraftType(UnitType[Type[FlyingType]]): ) return min(Speed.from_mach(0.35, altitude), max_speed * 0.5) + @cached_property + def preferred_cruise_altitude(self) -> Distance: + if self.cruise_altitude: + return self.cruise_altitude + else: + # TODO: somehow make the upper and lower limit configurable + return self.preferred_altitude(20, 20, "cruise") + + @cached_property + def preferred_combat_altitude(self) -> Distance: + if self.combat_altitude: + return self.combat_altitude + else: + # TODO: somehow make the upper and lower limit configurable + return self.preferred_altitude(20, 20, "combat") + + def preferred_altitude(self, low: int, high: int, type: str) -> Distance: + # Estimate based on max speed. + # Aircraft with max speed 600 kph will prefer low + # Aircraft with max speed 2800 kph will prefer high + altitude_for_lowest_speed = feet(low * 1000) + altitude_for_highest_speed = feet(high * 1000) + lowest_speed = kph(600) + highest_speed = kph(2800) + factor = (self.max_speed - lowest_speed).kph / ( + highest_speed - lowest_speed + ).kph + altitude = ( + altitude_for_lowest_speed + + (altitude_for_highest_speed - altitude_for_lowest_speed) * factor + ) + logging.debug( + f"Preferred {type} altitude for {self.dcs_unit_type.id}: {altitude.feet}" + ) + rounded_altitude = feet(round(1000 * round(altitude.feet / 1000))) + return max( + altitude_for_lowest_speed, + min(altitude_for_highest_speed, rounded_altitude), + ) + def alloc_flight_radio(self, radio_registry: RadioRegistry) -> RadioFrequency: from game.radio.radios import ChannelInUseError, kHz @@ -442,6 +480,7 @@ class AircraftType(UnitType[Type[FlyingType]]): radio_config = RadioConfig.from_data(data.get("radios", {})) patrol_config = PatrolConfig.from_data(data.get("patrol", {})) + altitudes_config = AltitudesConfig.from_data(data.get("altitudes", {})) try: mission_range = nautical_miles(int(data["max_range"])) @@ -510,6 +549,8 @@ class AircraftType(UnitType[Type[FlyingType]]): max_group_size=data.get("max_group_size", aircraft.group_size_max), patrol_altitude=patrol_config.altitude, patrol_speed=patrol_config.speed, + cruise_altitude=altitudes_config.cruise, + combat_altitude=altitudes_config.combat, max_mission_range=mission_range, fuel_consumption=fuel_consumption, default_livery=data.get("default_livery"), diff --git a/game/migrator.py b/game/migrator.py index 52f85444..1a7db5d6 100644 --- a/game/migrator.py +++ b/game/migrator.py @@ -52,7 +52,7 @@ class Migrator: continue found = False for d in doctrines: - if c.faction.doctrine.rendezvous_altitude == d.rendezvous_altitude: + if c.faction.doctrine.max_patrol_altitude == d.max_patrol_altitude: c.faction.doctrine = d found = True break diff --git a/game/missiongenerator/aircraft/waypoints/landingpoint.py b/game/missiongenerator/aircraft/waypoints/landingpoint.py index e1623af0..f3f948d3 100644 --- a/game/missiongenerator/aircraft/waypoints/landingpoint.py +++ b/game/missiongenerator/aircraft/waypoints/landingpoint.py @@ -8,9 +8,7 @@ class LandingPointBuilder(PydcsWaypointBuilder): def build(self) -> MovingPoint: waypoint = super().build() if self.ai_despawn(waypoint): - waypoint.alt = round( - self.flight.coalition.doctrine.max_patrol_altitude.meters - ) + waypoint.alt = round(self.flight.unit_type.preferred_patrol_altitude.meters) waypoint.alt_type = "BARO" else: waypoint.type = "Land" diff --git a/game/settings/settings.py b/game/settings/settings.py index 3c48a20a..149ba194 100644 --- a/game/settings/settings.py +++ b/game/settings/settings.py @@ -297,8 +297,18 @@ class Settings: page=CAMPAIGN_DOCTRINE_PAGE, section=GENERAL_SECTION, default=False, - detail=("AI will jettison their fuel tanks as soon as they're empty."), + detail="AI will jettison their fuel tanks as soon as they're empty.", ) + max_plane_altitude_offset: int = bounded_int_option( + "Maximum randomized altitude offset (x1000 ft) for airplanes.", + page=CAMPAIGN_DOCTRINE_PAGE, + section=GENERAL_SECTION, + min=0, + max=5, + default=2, + detail="Creates a randomized altitude offset for airplanes.", + ) + # Doctrine Distances Section airbase_threat_range: int = bounded_int_option( "Airbase threat range (nmi)", page=CAMPAIGN_DOCTRINE_PAGE, diff --git a/qt_ui/windows/mission/flight/waypoints/QFlightWaypointList.py b/qt_ui/windows/mission/flight/waypoints/QFlightWaypointList.py index 50fd6c40..bab179a6 100644 --- a/qt_ui/windows/mission/flight/waypoints/QFlightWaypointList.py +++ b/qt_ui/windows/mission/flight/waypoints/QFlightWaypointList.py @@ -86,7 +86,7 @@ class QFlightWaypointList(QTableView): self.model.setItem(row, 0, QWaypointItem(waypoint, row)) - altitude = int(waypoint.alt.feet) + altitude = round(waypoint.alt.feet) altitude_item = QStandardItem(f"{altitude}") altitude_item.setEditable(True) self.model.setItem(row, 1, altitude_item)