From e03d710d5366ff56b0eba895d991864deeaa8511 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Wed, 14 Jul 2021 21:59:46 -0700 Subject: [PATCH 01/42] [1/3] Rework IP placement. Test cases: 1. Target is not threatened. The IP should be placed on a direct heading from the origin to the target at the max ingress distance, or very near the origin airfield if the airfield is closer to the target than the IP distance. 2. Unthreatened home zone, max IP between origin and target, safe locations available for IP. The IP should be placed in LAR at the closest point to home. 3. Unthreatened home zone, origin within LAR, safe locations available for IP. The IP should be placed near the origin airfield to prevent backtracking more than needed. 4. Unthreatened home zone, origin entirely nearer the target than LAR, safe locations available for IP. The IP should be placed in LAR as close as possible to the origin. 5. Threatened home zone, safe locations available for IP. The IP should be placed in LAR as close as possible to the origin. 6. No safe IP. The IP should be placed in LAR at the point nearest the threat boundary. --- game/data/doctrine.py | 37 ++++++-- game/flightplan/__init__.py | 1 + game/flightplan/ipzonegeometry.py | 114 ++++++++++++++++++++++++ gen/flights/flightplan.py | 71 +++++---------- qt_ui/widgets/map/mapmodel.py | 94 +++++++++++++++++++- resources/ui/map/map.js | 139 +++++++++++++++++++++--------- 6 files changed, 354 insertions(+), 102 deletions(-) create mode 100644 game/flightplan/__init__.py create mode 100644 game/flightplan/ipzonegeometry.py diff --git a/game/data/doctrine.py b/game/data/doctrine.py index 4f944833..21402501 100644 --- a/game/data/doctrine.py +++ b/game/data/doctrine.py @@ -47,8 +47,13 @@ class Doctrine: #: fallback flight plan layout (when the departure airfield is near a threat zone). join_distance: Distance - #: The distance between the ingress point (beginning of the attack) and target. - ingress_distance: Distance + #: The maximum distance between the ingress point (beginning of the attack) and + #: target. + max_ingress_distance: Distance + + #: The minimum distance between the ingress point (beginning of the attack) and + #: target. + min_ingress_distance: Distance ingress_altitude: Distance @@ -87,9 +92,22 @@ class Doctrine: @has_save_compat_for(5) def __setstate__(self, state: dict[str, Any]) -> None: - if "ingress_distance" not in state: - state["ingress_distance"] = state["ingress_egress_distance"] - del state["ingress_egress_distance"] + if "max_ingress_distance" not in state: + try: + state["max_ingress_distance"] = state["ingress_distance"] + del state["ingress_distance"] + except KeyError: + state["max_ingress_distance"] = state["ingress_egress_distance"] + del state["ingress_egress_distance"] + + max_ip: Distance = state["max_ingress_distance"] + if "min_ingress_distance" not in state: + if max_ip < nautical_miles(10): + min_ip = nautical_miles(5) + else: + min_ip = nautical_miles(10) + state["min_ingress_distance"] = min_ip + self.__dict__.update(state) @@ -103,7 +121,8 @@ MODERN_DOCTRINE = Doctrine( hold_distance=nautical_miles(15), push_distance=nautical_miles(20), join_distance=nautical_miles(20), - ingress_distance=nautical_miles(45), + 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), @@ -139,7 +158,8 @@ COLDWAR_DOCTRINE = Doctrine( hold_distance=nautical_miles(10), push_distance=nautical_miles(10), join_distance=nautical_miles(10), - ingress_distance=nautical_miles(30), + 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), @@ -175,7 +195,8 @@ WWII_DOCTRINE = Doctrine( push_distance=nautical_miles(5), join_distance=nautical_miles(5), rendezvous_altitude=feet(10000), - ingress_distance=nautical_miles(7), + 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), diff --git a/game/flightplan/__init__.py b/game/flightplan/__init__.py new file mode 100644 index 00000000..51dce4a3 --- /dev/null +++ b/game/flightplan/__init__.py @@ -0,0 +1 @@ +from .ipzonegeometry import IpZoneGeometry diff --git a/game/flightplan/ipzonegeometry.py b/game/flightplan/ipzonegeometry.py new file mode 100644 index 00000000..92e647c2 --- /dev/null +++ b/game/flightplan/ipzonegeometry.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import shapely.ops +from dcs import Point +from shapely.geometry import Point as ShapelyPoint + +from game.utils import nautical_miles, meters + +if TYPE_CHECKING: + from game.coalition import Coalition + + +class IpZoneGeometry: + """Defines the zones used for finding optimal IP placement. + + The zones themselves are stored in the class rather than just the resulting IP so + that the zones can be drawn in the map for debugging purposes. + """ + + def __init__( + self, + target: Point, + home: Point, + coalition: Coalition, + ) -> None: + self.threat_zone = coalition.opponent.threat_zone.all + self.home = ShapelyPoint(home.x, home.y) + + max_ip_distance = coalition.doctrine.max_ingress_distance + min_ip_distance = coalition.doctrine.min_ingress_distance + + # The minimum distance between the home location and the IP. + min_distance_from_home = nautical_miles(5) + + # The distance that is expected to be needed between the beginning of the attack + # and weapon release. This buffers the threat zone to give a 5nm window between + # the edge of the "safe" zone and the actual threat so that "safe" IPs are less + # likely to end up with the attacker entering a threatened area. + attack_distance_buffer = nautical_miles(5) + + home_threatened = coalition.opponent.threat_zone.threatened(home) + + shapely_target = ShapelyPoint(target.x, target.y) + home_to_target_distance = meters(home.distance_to_point(target)) + + self.home_bubble = self.home.buffer(home_to_target_distance.meters).difference( + self.home.buffer(min_distance_from_home.meters) + ) + + # If the home zone is not threatened and home is within LAR, constrain the max + # range to the home-to-target distance to prevent excessive backtracking. + # + # If the home zone *is* threatened, we need to back out of the zone to + # rendezvous anyway. + if not home_threatened and ( + min_ip_distance < home_to_target_distance < max_ip_distance + ): + max_ip_distance = home_to_target_distance + max_ip_bubble = shapely_target.buffer(max_ip_distance.meters) + min_ip_bubble = shapely_target.buffer(min_ip_distance.meters) + self.ip_bubble = max_ip_bubble.difference(min_ip_bubble) + + # The intersection of the home bubble and IP bubble will be all the points that + # are within the valid IP range that are not farther from home than the target + # is. However, if the origin airfield is threatened but there are safe + # placements for the IP, we should not constrain to the home zone. In this case + # we'll either end up with a safe zone outside the home zone and pick the + # closest point in to to home (minimizing backtracking), or we'll have no safe + # IP anywhere within range of the target, and we'll later pick the IP nearest + # the edge of the threat zone. + if home_threatened: + self.permissible_zone = self.ip_bubble + else: + self.permissible_zone = self.ip_bubble.intersection(self.home_bubble) + + if self.permissible_zone.is_empty: + # If home is closer to the target than the min range, there will not be an + # IP solution that's close enough to home, in which case we need to ignore + # the home bubble. + self.permissible_zone = self.ip_bubble + + self.safe_zone = self.permissible_zone.difference( + self.threat_zone.buffer(attack_distance_buffer.meters) + ) + + def _unsafe_ip(self) -> ShapelyPoint: + unthreatened_home_zone = self.home_bubble.difference(self.threat_zone) + if unthreatened_home_zone.is_empty: + # Nowhere in our home zone is safe. The package will need to exit the + # threatened area to hold and rendezvous. Pick the IP closest to the + # edge of the threat zone. + return shapely.ops.nearest_points( + self.permissible_zone, self.threat_zone.boundary + )[0] + + # No safe point in the IP zone, but the home zone is safe. Pick the max- + # distance IP that's closest to the untreatened home zone. + return shapely.ops.nearest_points( + self.permissible_zone, unthreatened_home_zone + )[0] + + def _safe_ip(self) -> ShapelyPoint: + # We have a zone of possible IPs that are safe, close enough, and in range. Pick + # the IP in the zone that's closest to the target. + return shapely.ops.nearest_points(self.safe_zone, self.home)[0] + + def find_best_ip(self) -> Point: + if self.safe_zone.is_empty: + ip = self._unsafe_ip() + else: + ip = self._safe_ip() + return Point(ip.x, ip.y) diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py index 0927b968..13295d01 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -20,6 +20,7 @@ from dcs.unit import Unit from shapely.geometry import Point as ShapelyPoint from game.data.doctrine import Doctrine +from game.flightplan import IpZoneGeometry from game.theater import ( Airfield, ControlPoint, @@ -946,57 +947,35 @@ class FlightPlanBuilder: raise PlanningError(f"{task} flight plan generation not implemented") def regenerate_package_waypoints(self) -> None: - # The simple case is where the target is not near the departure airfield. In - # this case, we can plan the shortest route from the departure airfield to the - # target, use the nearest non-threatened point *that's farther from the target - # than the ingress point to avoid backtracking) as the join point. - # - # The other case that we need to handle is when the target is close to - # the origin airfield. In this case we currently fall back to the old planning - # behavior. - # - # A messy (and very unlikely) case that we can't do much about: - # - # +--------------+ +---------------+ - # | | | | - # | IP-+---+-T | - # | | | | - # | | | | - # +--------------+ +---------------+ from gen.ato import PackageWaypoints - target = self.package.target.position + # Start by picking the best IP for the attack. + ingress_point = IpZoneGeometry( + self.package.target.position, + self.package_airfield().position, + self.coalition, + ).find_best_ip() - for join_point in self.preferred_join_points(): - join_distance = meters(join_point.distance_to_point(target)) - if join_distance > self.doctrine.ingress_distance: - break - else: + # Pick the join point based on the best route to the IP. + join_point = self.preferred_join_point(ingress_point) + if join_point is None: # The entire path to the target is threatened. Use the fallback behavior for # now. - self.legacy_package_waypoints_impl() + self.legacy_package_waypoints_impl(ingress_point) return - attack_heading = join_point.heading_between_point(target) - ingress_point = self._ingress_point(attack_heading) - - # The first case described above. The ingress and join points are placed - # reasonably relative to each other. + # And the split point based on the best route from the IP. Since that's no + # different than the best route *to* the IP, this is the same as the join point. + # TODO: Estimate attack completion point based on the IP and split from there? self.package.waypoints = PackageWaypoints( WaypointBuilder.perturb(join_point), ingress_point, - WaypointBuilder.perturb( - self.preferred_split_point(ingress_point, join_point) - ), + WaypointBuilder.perturb(join_point), ) - def retreat_point(self, origin: Point) -> Point: - return self.threat_zones.closest_boundary(origin) - - def legacy_package_waypoints_impl(self) -> None: + def legacy_package_waypoints_impl(self, ingress_point: Point) -> None: from gen.ato import PackageWaypoints - ingress_point = self._ingress_point(self._target_heading_to_package_airfield()) join_point = self._rendezvous_point(ingress_point) self.package.waypoints = PackageWaypoints( WaypointBuilder.perturb(join_point), @@ -1009,23 +988,15 @@ class FlightPlanBuilder: if not self.threat_zones.threatened(point): yield point - def preferred_join_points(self) -> Iterator[Point]: + def preferred_join_point(self, ingress_point: Point) -> Optional[Point]: # Use non-threatened points along the path to the target as the join point. We # may need to try more than one in the event that the close non-threatened # points are closer than the ingress point itself. - return self.safe_points_between( - self.package.target.position, self.package_airfield().position - ) - - def preferred_split_point(self, ingress_point: Point, join_point: Point) -> Point: - # Use non-threatened points along the path to the target as the join point. We - # may need to try more than one in the event that the close non-threatened - # points are closer than the ingress point itself. - for point in self.safe_points_between( + for join_point in self.safe_points_between( ingress_point, self.package_airfield().position ): - return point - return join_point + return join_point + return None def generate_strike(self, flight: Flight) -> StrikeFlightPlan: """Generates a strike flight plan. @@ -1847,7 +1818,7 @@ class FlightPlanBuilder: def _ingress_point(self, heading: float) -> Point: return self.package.target.position.point_from_heading( - heading - 180, self.doctrine.ingress_distance.meters + heading - 180, self.doctrine.max_ingress_distance.meters ) def _target_heading_to_package_airfield(self) -> float: diff --git a/qt_ui/widgets/map/mapmodel.py b/qt_ui/widgets/map/mapmodel.py index 02eadd9f..c2e6706a 100644 --- a/qt_ui/widgets/map/mapmodel.py +++ b/qt_ui/widgets/map/mapmodel.py @@ -27,7 +27,12 @@ from game.transfers import MultiGroupTransport, TransportMap from game.utils import meters, nautical_miles from gen.ato import AirTaskingOrder from gen.flights.flight import Flight, FlightWaypoint, FlightWaypointType -from gen.flights.flightplan import FlightPlan, PatrollingFlightPlan, CasFlightPlan +from gen.flights.flightplan import ( + FlightPlan, + PatrollingFlightPlan, + CasFlightPlan, +) +from game.flightplan.ipzonegeometry import IpZoneGeometry from qt_ui.dialogs import Dialog from qt_ui.models import GameModel, AtoModel from qt_ui.windows.GameUpdateSignal import GameUpdateSignal @@ -39,6 +44,10 @@ LeafletPoly = list[LeafletLatLon] MAX_SHIP_DISTANCE = nautical_miles(80) +# Set to True to enable computing expensive debugging information. At the time of +# writing this only controls computing the waypoint placement zones. +ENABLE_EXPENSIVE_DEBUG_TOOLS = False + # **EVERY PROPERTY NEEDS A NOTIFY SIGNAL** # # https://bugreports.qt.io/browse/PYSIDE-1426 @@ -512,6 +521,19 @@ class FlightJs(QObject): selectedChanged = Signal() commitBoundaryChanged = Signal() + originChanged = Signal() + + @Property(list, notify=originChanged) + def origin(self) -> LeafletLatLon: + return self._waypoints[0].position + + targetChanged = Signal() + + @Property(list, notify=targetChanged) + def target(self) -> LeafletLatLon: + ll = self.theater.point_to_ll(self.flight.package.target.position) + return [ll.latitude, ll.longitude] + def __init__( self, flight: Flight, @@ -769,6 +791,56 @@ class UnculledZone(QObject): ) +class IpZonesJs(QObject): + homeBubbleChanged = Signal() + ipBubbleChanged = Signal() + permissibleZoneChanged = Signal() + safeZoneChanged = Signal() + + def __init__( + self, + home_bubble: list[LeafletPoly], + ip_bubble: list[LeafletPoly], + permissible_zone: list[LeafletPoly], + safe_zone: list[LeafletPoly], + ) -> None: + super().__init__() + self._home_bubble = home_bubble + self._ip_bubble = ip_bubble + self._permissible_zone = permissible_zone + self._safe_zone = safe_zone + + @Property(list, notify=homeBubbleChanged) + def homeBubble(self) -> list[LeafletPoly]: + return self._home_bubble + + @Property(list, notify=ipBubbleChanged) + def ipBubble(self) -> list[LeafletPoly]: + return self._ip_bubble + + @Property(list, notify=permissibleZoneChanged) + def permissibleZone(self) -> list[LeafletPoly]: + return self._permissible_zone + + @Property(list, notify=permissibleZoneChanged) + def safeZone(self) -> list[LeafletPoly]: + return self._safe_zone + + @classmethod + def for_flight(cls, flight: Flight, game: Game) -> IpZonesJs: + target = flight.package.target + home = flight.departure + if not ENABLE_EXPENSIVE_DEBUG_TOOLS or target == home: + return IpZonesJs([], [], [], []) + geometry = IpZoneGeometry(target.position, home.position, game.blue) + return IpZonesJs( + shapely_to_leaflet_polys(geometry.home_bubble, game.theater), + shapely_to_leaflet_polys(geometry.ip_bubble, game.theater), + shapely_to_leaflet_polys(geometry.permissible_zone, game.theater), + shapely_to_leaflet_polys(geometry.safe_zone, game.theater), + ) + + class MapModel(QObject): cleared = Signal() @@ -782,6 +854,7 @@ class MapModel(QObject): navmeshesChanged = Signal() mapZonesChanged = Signal() unculledZonesChanged = Signal() + ipZonesChanged = Signal() def __init__(self, game_model: GameModel) -> None: super().__init__() @@ -798,6 +871,7 @@ class MapModel(QObject): self._navmeshes = NavMeshJs([], []) self._map_zones = MapZonesJs([], [], []) self._unculled_zones = [] + self._ip_zones = IpZonesJs([], [], [], []) self._selected_flight_index: Optional[Tuple[int, int]] = None GameUpdateSignal.get_instance().game_loaded.connect(self.on_game_load) GameUpdateSignal.get_instance().flight_paths_changed.connect(self.reset_atos) @@ -821,6 +895,7 @@ class MapModel(QObject): self._navmeshes = NavMeshJs([], []) self._map_zones = MapZonesJs([], [], []) self._unculled_zones = [] + self._ip_zones = IpZonesJs([], [], [], []) self.cleared.emit() def set_package_selection(self, index: int) -> None: @@ -896,11 +971,24 @@ class MapModel(QObject): ) return flights + def _get_selected_flight(self) -> Optional[Flight]: + for p_idx, package in enumerate(self.game.blue.ato.packages): + for f_idx, flight in enumerate(package.flights): + if (p_idx, f_idx) == self._selected_flight_index: + return flight + return None + def reset_atos(self) -> None: self._flights = self._flights_in_ato( self.game.blue.ato, blue=True ) + self._flights_in_ato(self.game.red.ato, blue=False) self.flightsChanged.emit() + selected_flight = self._get_selected_flight() + if selected_flight is not None: + self._ip_zones = IpZonesJs.for_flight(selected_flight, self.game) + else: + self._ip_zones = IpZonesJs([], [], [], []) + self.ipZonesChanged.emit() @Property(list, notify=flightsChanged) def flights(self) -> List[FlightJs]: @@ -1029,6 +1117,10 @@ class MapModel(QObject): def unculledZones(self) -> list[UnculledZone]: return self._unculled_zones + @Property(IpZonesJs, notify=ipZonesChanged) + def ipZones(self) -> IpZonesJs: + return self._ip_zones + @property def game(self) -> Game: if self.game_model.game is None: diff --git a/resources/ui/map/map.js b/resources/ui/map/map.js index 6cae9ae2..e56a9fc0 100644 --- a/resources/ui/map/map.js +++ b/resources/ui/map/map.js @@ -1,3 +1,7 @@ +// Won't actually enable anything unless the same property is set in +// mapmodel.py. +const ENABLE_EXPENSIVE_DEBUG_TOOLS = false; + const Colors = Object.freeze({ Blue: "#0084ff", Red: "#c85050", @@ -124,26 +128,26 @@ const map = L.map("map", { L.control.scale({ maxWidth: 200 }).addTo(map); const rulerOptions = { - position: 'topleft', + position: "topleft", circleMarker: { color: Colors.Highlight, - radius: 2 + radius: 2, }, lineStyle: { color: Colors.Highlight, - dashArray: '1,6' + dashArray: "1,6", }, lengthUnit: { display: "nm", decimal: "2", factor: 0.539956803, - label: "Distance:" + label: "Distance:", }, angleUnit: { - display: '°', + display: "°", decimal: 0, - label: "Bearing:" - } + label: "Bearing:", + }, }; L.control.ruler(rulerOptions).addTo(map); @@ -194,6 +198,48 @@ const exclusionZones = L.layerGroup(); const seaZones = L.layerGroup(); const unculledZones = L.layerGroup(); +const homeBubble = L.layerGroup(); +const ipBubble = L.layerGroup(); +const permissibleZone = L.layerGroup(); +const safeZone = L.layerGroup(); + +const debugControlGroups = { + "Blue Threat Zones": { + Hide: L.layerGroup().addTo(map), + Full: blueFullThreatZones, + Aircraft: blueAircraftThreatZones, + "Air Defenses": blueAirDefenseThreatZones, + "Radar SAMs": blueRadarSamThreatZones, + }, + "Red Threat Zones": { + Hide: L.layerGroup().addTo(map), + Full: redFullThreatZones, + Aircraft: redAircraftThreatZones, + "Air Defenses": redAirDefenseThreatZones, + "Radar SAMs": redRadarSamThreatZones, + }, + Navmeshes: { + Hide: L.layerGroup().addTo(map), + Blue: blueNavmesh, + Red: redNavmesh, + }, + "Map Zones": { + "Inclusion zones": inclusionZones, + "Exclusion zones": exclusionZones, + "Sea zones": seaZones, + "Culling exclusion zones": unculledZones, + }, +}; + +if (ENABLE_EXPENSIVE_DEBUG_TOOLS) { + debugControlGroups["IP Zones"] = { + "Home bubble": homeBubble, + "IP bubble": ipBubble, + "Permissible zone": permissibleZone, + "Safe zone": safeZone, + }; +} + // Main map controls. These are the ones that we expect users to interact with. // These are always open, which unfortunately means that the scroll bar will not // appear if the menu doesn't fit. This fits in the smallest window size we @@ -239,41 +285,11 @@ L.control // Debug map controls. Hover over to open. Not something most users will want or // need to interact with. L.control - .groupedLayers( - null, - { - "Blue Threat Zones": { - Hide: L.layerGroup().addTo(map), - Full: blueFullThreatZones, - Aircraft: blueAircraftThreatZones, - "Air Defenses": blueAirDefenseThreatZones, - "Radar SAMs": blueRadarSamThreatZones, - }, - "Red Threat Zones": { - Hide: L.layerGroup().addTo(map), - Full: redFullThreatZones, - Aircraft: redAircraftThreatZones, - "Air Defenses": redAirDefenseThreatZones, - "Radar SAMs": redRadarSamThreatZones, - }, - Navmeshes: { - Hide: L.layerGroup().addTo(map), - Blue: blueNavmesh, - Red: redNavmesh, - }, - "Map Zones": { - "Inclusion zones": inclusionZones, - "Exclusion zones": exclusionZones, - "Sea zones": seaZones, - "Culling exclusion zones": unculledZones, - }, - }, - { - position: "topleft", - exclusiveGroups: ["Blue Threat Zones", "Red Threat Zones", "Navmeshes"], - groupCheckboxes: true, - } - ) + .groupedLayers(null, debugControlGroups, { + position: "topleft", + exclusiveGroups: ["Blue Threat Zones", "Red Threat Zones", "Navmeshes"], + groupCheckboxes: true, + }) .addTo(map); let game; @@ -291,6 +307,7 @@ new QWebChannel(qt.webChannelTransport, function (channel) { game.navmeshesChanged.connect(drawNavmeshes); game.mapZonesChanged.connect(drawMapZones); game.unculledZonesChanged.connect(drawUnculledZones); + game.ipZonesChanged.connect(drawIpZones); }); function recenterMap(center) { @@ -570,7 +587,11 @@ class TheaterGroundObject { } L.marker(this.tgo.position, { icon: this.icon() }) - .bindTooltip(`${this.tgo.name} (${this.tgo.controlPointName})
${this.tgo.units.join("
")}`) + .bindTooltip( + `${this.tgo.name} (${ + this.tgo.controlPointName + })
${this.tgo.units.join("
")}` + ) .on("click", () => this.tgo.showInfoDialog()) .on("contextmenu", () => this.tgo.showPackageDialog()) .addTo(this.layer()); @@ -970,6 +991,37 @@ function drawUnculledZones() { } } +function drawIpZones() { + homeBubble.clearLayers(); + ipBubble.clearLayers(); + permissibleZone.clearLayers(); + safeZone.clearLayers(); + + L.polygon(game.ipZones.homeBubble, { + color: Colors.Highlight, + fillOpacity: 0.1, + interactive: false, + }).addTo(homeBubble); + + L.polygon(game.ipZones.ipBubble, { + color: "#bb89ff", + fillOpacity: 0.1, + interactive: false, + }).addTo(ipBubble); + + L.polygon(game.ipZones.permissibleZone, { + color: "#ffffff", + fillOpacity: 0.1, + interactive: false, + }).addTo(permissibleZone); + + L.polygon(game.ipZones.safeZone, { + color: Colors.Green, + fillOpacity: 0.1, + interactive: false, + }).addTo(safeZone); +} + function drawInitialMap() { recenterMap(game.mapCenter); drawControlPoints(); @@ -981,6 +1033,7 @@ function drawInitialMap() { drawNavmeshes(); drawMapZones(); drawUnculledZones(); + drawIpZones(); } function clearAllLayers() { From d444d716f53ab10daa92ccd122bd422779312888 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Thu, 15 Jul 2021 18:49:07 -0700 Subject: [PATCH 02/42] [2/3] Improve join point placement. --- game/flightplan/__init__.py | 1 + game/flightplan/joinzonegeometry.py | 84 +++++++++++++++++++++++ gen/flights/flightplan.py | 20 +++--- qt_ui/widgets/map/mapmodel.py | 103 +++++++++++++++++++++++++--- resources/ui/map/map.js | 70 ++++++++++++++----- 5 files changed, 243 insertions(+), 35 deletions(-) create mode 100644 game/flightplan/joinzonegeometry.py diff --git a/game/flightplan/__init__.py b/game/flightplan/__init__.py index 51dce4a3..42c4e3e9 100644 --- a/game/flightplan/__init__.py +++ b/game/flightplan/__init__.py @@ -1 +1,2 @@ from .ipzonegeometry import IpZoneGeometry +from .joinzonegeometry import JoinZoneGeometry diff --git a/game/flightplan/joinzonegeometry.py b/game/flightplan/joinzonegeometry.py new file mode 100644 index 00000000..39c7d640 --- /dev/null +++ b/game/flightplan/joinzonegeometry.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import shapely.ops +from dcs import Point +from shapely.geometry import Point as ShapelyPoint, Polygon + +from game.theater import ConflictTheater +from game.utils import nautical_miles + +if TYPE_CHECKING: + from game.coalition import Coalition + + +class JoinZoneGeometry: + """Defines the zones used for finding optimal join point placement. + + The zones themselves are stored in the class rather than just the resulting join + point so that the zones can be drawn in the map for debugging purposes. + """ + + def __init__( + self, + target: Point, + home: Point, + ip: Point, + coalition: Coalition, + theater: ConflictTheater, + ) -> None: + # Normal join placement is based on the path from home to the IP. If no path is + # found it means that the target is on a direct path. In that case we instead + # want to enforce that the join point is: + # + # * Not closer to the target than the IP. + # * Not too close to the home airfield. + # * Not threatened. + # * A minimum distance from the IP. + # * Not too sharp a turn at the ingress point. + self.ip = ShapelyPoint(ip.x, ip.y) + self.threat_zone = coalition.opponent.threat_zone.all + self.home = ShapelyPoint(home.x, home.y) + + self.ip_bubble = self.ip.buffer(coalition.doctrine.join_distance.meters) + + ip_distance = ip.distance_to_point(target) + self.target_bubble = ShapelyPoint(target.x, target.y).buffer(ip_distance) + + # The minimum distance between the home location and the IP. + min_distance_from_home = nautical_miles(5) + + self.home_bubble = self.home.buffer(min_distance_from_home.meters) + + self.excluded_zone = shapely.ops.unary_union( + [self.home_bubble, self.ip_bubble, self.target_bubble, self.threat_zone] + ) + + ip_heading = target.heading_between_point(ip) + + # Arbitrarily large since this is later constrained by the map boundary, and + # we'll be picking a location close to the IP anyway. Just used to avoid real + # distance calculations to project to the map edge. + large_distance = nautical_miles(400).meters + turn_limit = 40 + ip_limit_ccw = ip.point_from_heading(ip_heading - turn_limit, large_distance) + ip_limit_cw = ip.point_from_heading(ip_heading + turn_limit, large_distance) + + ip_direction_limit_wedge = Polygon( + [ + (ip.x, ip.y), + (ip_limit_ccw.x, ip_limit_ccw.y), + (ip_limit_cw.x, ip_limit_cw.y), + ] + ) + + self.permissible_line = ( + coalition.nav_mesh.map_bounds(theater) + .intersection(ip_direction_limit_wedge) + .intersection(self.excluded_zone.boundary) + ) + + def find_best_join_point(self) -> Point: + join, _ = shapely.ops.nearest_points(self.permissible_line, self.home) + return Point(join.x, join.y) diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py index 13295d01..5c1c0820 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -20,7 +20,7 @@ from dcs.unit import Unit from shapely.geometry import Point as ShapelyPoint from game.data.doctrine import Doctrine -from game.flightplan import IpZoneGeometry +from game.flightplan import IpZoneGeometry, JoinZoneGeometry from game.theater import ( Airfield, ControlPoint, @@ -949,20 +949,22 @@ class FlightPlanBuilder: def regenerate_package_waypoints(self) -> None: from gen.ato import PackageWaypoints + package_airfield = self.package_airfield() + # Start by picking the best IP for the attack. ingress_point = IpZoneGeometry( self.package.target.position, - self.package_airfield().position, + package_airfield.position, self.coalition, ).find_best_ip() - # Pick the join point based on the best route to the IP. - join_point = self.preferred_join_point(ingress_point) - if join_point is None: - # The entire path to the target is threatened. Use the fallback behavior for - # now. - self.legacy_package_waypoints_impl(ingress_point) - return + join_point = JoinZoneGeometry( + self.package.target.position, + package_airfield.position, + ingress_point, + self.coalition, + self.theater, + ).find_best_join_point() # And the split point based on the best route from the IP. Since that's no # different than the best route *to* the IP, this is the same as the join point. diff --git a/qt_ui/widgets/map/mapmodel.py b/qt_ui/widgets/map/mapmodel.py index c2e6706a..09bc6022 100644 --- a/qt_ui/widgets/map/mapmodel.py +++ b/qt_ui/widgets/map/mapmodel.py @@ -12,6 +12,7 @@ from shapely.geometry import LineString, Point as ShapelyPoint, Polygon, MultiPo from game import Game from game.dcs.groundunittype import GroundUnitType +from game.flightplan import JoinZoneGeometry from game.navmesh import NavMesh, NavMeshPoly from game.profiling import logged_duration from game.theater import ( @@ -82,6 +83,12 @@ def shapely_to_leaflet_polys( return [shapely_poly_to_leaflet_points(poly, theater) for poly in polys] +def shapely_line_to_leaflet_points( + line: LineString, theater: ConflictTheater +) -> list[LeafletLatLon]: + return [theater.point_to_ll(Point(x, y)).as_list() for x, y in line.coords] + + class ControlPointJs(QObject): nameChanged = Signal() blueChanged = Signal() @@ -822,16 +829,20 @@ class IpZonesJs(QObject): def permissibleZone(self) -> list[LeafletPoly]: return self._permissible_zone - @Property(list, notify=permissibleZoneChanged) + @Property(list, notify=safeZoneChanged) def safeZone(self) -> list[LeafletPoly]: return self._safe_zone + @classmethod + def empty(cls) -> IpZonesJs: + return IpZonesJs([], [], [], []) + @classmethod def for_flight(cls, flight: Flight, game: Game) -> IpZonesJs: + if not ENABLE_EXPENSIVE_DEBUG_TOOLS: + return IpZonesJs.empty() target = flight.package.target home = flight.departure - if not ENABLE_EXPENSIVE_DEBUG_TOOLS or target == home: - return IpZonesJs([], [], [], []) geometry = IpZoneGeometry(target.position, home.position, game.blue) return IpZonesJs( shapely_to_leaflet_polys(geometry.home_bubble, game.theater), @@ -841,6 +852,73 @@ class IpZonesJs(QObject): ) +class JoinZonesJs(QObject): + homeBubbleChanged = Signal() + targetBubbleChanged = Signal() + ipBubbleChanged = Signal() + excludedZoneChanged = Signal() + permissibleLineChanged = Signal() + + def __init__( + self, + home_bubble: list[LeafletPoly], + target_bubble: list[LeafletPoly], + ip_bubble: list[LeafletPoly], + excluded_zone: list[LeafletPoly], + permissible_line: list[LeafletLatLon], + ) -> None: + super().__init__() + self._home_bubble = home_bubble + self._target_bubble = target_bubble + self._ip_bubble = ip_bubble + self._excluded_zone = excluded_zone + self._permissible_line = permissible_line + + @Property(list, notify=homeBubbleChanged) + def homeBubble(self) -> list[LeafletPoly]: + return self._home_bubble + + @Property(list, notify=targetBubbleChanged) + def targetBubble(self) -> list[LeafletPoly]: + return self._target_bubble + + @Property(list, notify=ipBubbleChanged) + def ipBubble(self) -> list[LeafletPoly]: + return self._ip_bubble + + @Property(list, notify=excludedZoneChanged) + def excludedZone(self) -> list[LeafletPoly]: + return self._excluded_zone + + @Property(list, notify=permissibleLineChanged) + def permissibleLine(self) -> list[LeafletLatLon]: + return self._permissible_line + + @classmethod + def empty(cls) -> JoinZonesJs: + return JoinZonesJs([], [], [], [], []) + + @classmethod + def for_flight(cls, flight: Flight, game: Game) -> JoinZonesJs: + if not ENABLE_EXPENSIVE_DEBUG_TOOLS: + return JoinZonesJs.empty() + target = flight.package.target + home = flight.departure + if flight.package.waypoints is None: + return JoinZonesJs.empty() + ip = flight.package.waypoints.ingress + geometry = JoinZoneGeometry( + target.position, home.position, ip, game.blue, game.theater + ) + return JoinZonesJs( + shapely_to_leaflet_polys(geometry.home_bubble, game.theater), + shapely_to_leaflet_polys(geometry.target_bubble, game.theater), + shapely_to_leaflet_polys(geometry.ip_bubble, game.theater), + shapely_to_leaflet_polys(geometry.excluded_zone, game.theater), + shapely_line_to_leaflet_points(geometry.permissible_line, game.theater), + ) + + class MapModel(QObject): cleared = Signal() @@ -855,6 +933,7 @@ class MapModel(QObject): mapZonesChanged = Signal() unculledZonesChanged = Signal() ipZonesChanged = Signal() + joinZonesChanged = Signal() def __init__(self, game_model: GameModel) -> None: super().__init__() @@ -871,7 +950,8 @@ class MapModel(QObject): self._navmeshes = NavMeshJs([], []) self._map_zones = MapZonesJs([], [], []) self._unculled_zones = [] - self._ip_zones = IpZonesJs([], [], [], []) + self._ip_zones = IpZonesJs.empty() + self._join_zones = JoinZonesJs.empty() self._selected_flight_index: Optional[Tuple[int, int]] = None GameUpdateSignal.get_instance().game_loaded.connect(self.on_game_load) GameUpdateSignal.get_instance().flight_paths_changed.connect(self.reset_atos) @@ -895,7 +975,7 @@ class MapModel(QObject): self._navmeshes = NavMeshJs([], []) self._map_zones = MapZonesJs([], [], []) self._unculled_zones = [] - self._ip_zones = IpZonesJs([], [], [], []) + self._ip_zones = IpZonesJs.empty() self.cleared.emit() def set_package_selection(self, index: int) -> None: @@ -984,11 +1064,14 @@ class MapModel(QObject): ) + self._flights_in_ato(self.game.red.ato, blue=False) self.flightsChanged.emit() selected_flight = self._get_selected_flight() - if selected_flight is not None: - self._ip_zones = IpZonesJs.for_flight(selected_flight, self.game) + if selected_flight is None: + self._ip_zones = IpZonesJs.empty() + self._join_zones = JoinZonesJs.empty() else: - self._ip_zones = IpZonesJs([], [], [], []) + self._ip_zones = IpZonesJs.for_flight(selected_flight, self.game) + self._join_zones = JoinZonesJs.for_flight(selected_flight, self.game) self.ipZonesChanged.emit() + self.joinZonesChanged.emit() @Property(list, notify=flightsChanged) def flights(self) -> List[FlightJs]: @@ -1121,6 +1204,10 @@ class MapModel(QObject): def ipZones(self) -> IpZonesJs: return self._ip_zones + @Property(JoinZonesJs, notify=joinZonesChanged) + def joinZones(self) -> JoinZonesJs: + return self._join_zones + @property def game(self) -> Game: if self.game_model.game is None: diff --git a/resources/ui/map/map.js b/resources/ui/map/map.js index e56a9fc0..87e4ca7e 100644 --- a/resources/ui/map/map.js +++ b/resources/ui/map/map.js @@ -198,10 +198,8 @@ const exclusionZones = L.layerGroup(); const seaZones = L.layerGroup(); const unculledZones = L.layerGroup(); -const homeBubble = L.layerGroup(); -const ipBubble = L.layerGroup(); -const permissibleZone = L.layerGroup(); -const safeZone = L.layerGroup(); +const ipZones = L.layerGroup(); +const joinZones = L.layerGroup().addTo(map); const debugControlGroups = { "Blue Threat Zones": { @@ -232,11 +230,9 @@ const debugControlGroups = { }; if (ENABLE_EXPENSIVE_DEBUG_TOOLS) { - debugControlGroups["IP Zones"] = { - "Home bubble": homeBubble, - "IP bubble": ipBubble, - "Permissible zone": permissibleZone, - "Safe zone": safeZone, + debugControlGroups["Waypoint Zones"] = { + "IP Zones": ipZones, + "Join Zones": joinZones, }; } @@ -287,7 +283,12 @@ L.control L.control .groupedLayers(null, debugControlGroups, { position: "topleft", - exclusiveGroups: ["Blue Threat Zones", "Red Threat Zones", "Navmeshes"], + exclusiveGroups: [ + "Blue Threat Zones", + "Red Threat Zones", + "Navmeshes", + "Waypoint Zones", + ], groupCheckboxes: true, }) .addTo(map); @@ -308,6 +309,7 @@ new QWebChannel(qt.webChannelTransport, function (channel) { game.mapZonesChanged.connect(drawMapZones); game.unculledZonesChanged.connect(drawUnculledZones); game.ipZonesChanged.connect(drawIpZones); + game.joinZonesChanged.connect(drawJoinZones); }); function recenterMap(center) { @@ -992,34 +994,65 @@ function drawUnculledZones() { } function drawIpZones() { - homeBubble.clearLayers(); - ipBubble.clearLayers(); - permissibleZone.clearLayers(); - safeZone.clearLayers(); + ipZones.clearLayers(); L.polygon(game.ipZones.homeBubble, { color: Colors.Highlight, fillOpacity: 0.1, interactive: false, - }).addTo(homeBubble); + }).addTo(ipZones); L.polygon(game.ipZones.ipBubble, { color: "#bb89ff", fillOpacity: 0.1, interactive: false, - }).addTo(ipBubble); + }).addTo(ipZones); L.polygon(game.ipZones.permissibleZone, { color: "#ffffff", fillOpacity: 0.1, interactive: false, - }).addTo(permissibleZone); + }).addTo(ipZones); L.polygon(game.ipZones.safeZone, { color: Colors.Green, fillOpacity: 0.1, interactive: false, - }).addTo(safeZone); + }).addTo(ipZones); +} + +function drawJoinZones() { + joinZones.clearLayers(); + + L.polygon(game.joinZones.homeBubble, { + color: Colors.Highlight, + fillOpacity: 0.1, + interactive: false, + }).addTo(joinZones); + + L.polygon(game.joinZones.targetBubble, { + color: "#bb89ff", + fillOpacity: 0.1, + interactive: false, + }).addTo(joinZones); + + L.polygon(game.joinZones.ipBubble, { + color: "#ffffff", + fillOpacity: 0.1, + interactive: false, + }).addTo(joinZones); + + L.polygon(game.joinZones.excludedZone, { + color: "#ffa500", + fillOpacity: 0.2, + stroke: false, + interactive: false, + }).addTo(joinZones); + + L.polyline(game.joinZones.permissibleLine, { + color: Colors.Green, + interactive: false, + }).addTo(joinZones); } function drawInitialMap() { @@ -1034,6 +1067,7 @@ function drawInitialMap() { drawMapZones(); drawUnculledZones(); drawIpZones(); + drawJoinZones(); } function clearAllLayers() { From 82cca0a602a8316f709be88c6f5f2a8084873ad4 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Thu, 15 Jul 2021 22:18:53 -0700 Subject: [PATCH 03/42] [3/3] Rework hold points. --- changelog.md | 1 + game/flightplan/__init__.py | 1 + game/flightplan/holdzonegeometry.py | 108 +++++++++++++++++ game/flightplan/ipzonegeometry.py | 12 +- game/flightplan/joinzonegeometry.py | 29 +++-- gen/flights/flightplan.py | 126 +------------------- qt_ui/widgets/map/mapmodel.py | 179 +++++++++++++++++++++------- resources/ui/map/map.js | 80 ++++++++++--- 8 files changed, 346 insertions(+), 190 deletions(-) create mode 100644 game/flightplan/holdzonegeometry.py diff --git a/changelog.md b/changelog.md index 9be996fd..7722fcd7 100644 --- a/changelog.md +++ b/changelog.md @@ -7,6 +7,7 @@ Saves from 3.x are not compatible with 5.0. * **[Campaign]** Weapon data such as fallbacks and introduction years is now moddable. Due to the new architecture to support this, the old data was not automatically migrated. * **[Campaign AI]** Overhauled campaign AI target prioritization. This currently only affects the ordering of DEAD missions. * **[Campaign AI]** Player front line stances can now be automated. Improved stance selection for AI. +* **[Campaign AI]** Reworked layout of hold, join, split, and ingress points. Should result in much shorter flight plans in general while still maintaining safe join/split/hold points. ## Fixes diff --git a/game/flightplan/__init__.py b/game/flightplan/__init__.py index 42c4e3e9..17a92708 100644 --- a/game/flightplan/__init__.py +++ b/game/flightplan/__init__.py @@ -1,2 +1,3 @@ +from .holdzonegeometry import HoldZoneGeometry from .ipzonegeometry import IpZoneGeometry from .joinzonegeometry import JoinZoneGeometry diff --git a/game/flightplan/holdzonegeometry.py b/game/flightplan/holdzonegeometry.py new file mode 100644 index 00000000..b382e11a --- /dev/null +++ b/game/flightplan/holdzonegeometry.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import shapely.ops +from dcs import Point +from shapely.geometry import Point as ShapelyPoint, Polygon, MultiPolygon + +from game.theater import ConflictTheater +from game.utils import nautical_miles + +if TYPE_CHECKING: + from game.coalition import Coalition + + +class HoldZoneGeometry: + """Defines the zones used for finding optimal hold point placement. + + The zones themselves are stored in the class rather than just the resulting hold + point so that the zones can be drawn in the map for debugging purposes. + """ + + def __init__( + self, + target: Point, + home: Point, + ip: Point, + join: Point, + coalition: Coalition, + theater: ConflictTheater, + ) -> None: + # Hold points are placed one of two ways. Either approach guarantees: + # + # * Safe hold point. + # * Minimum distance to the join point. + # * Not closer to the target than the join point. + # + # 1. As near the join point as possible with a specific distance from the + # departure airfield. This prevents loitering directly above the airfield but + # also keeps the hold point close to the departure airfield. + # + # 2. Alternatively, if the entire home zone is excluded by the above criteria, + # as neat the departure airfield as possible within a minimum distance from + # the join point, with a restricted turn angle at the join point. This + # handles the case where we need to backtrack from the departure airfield and + # the join point to place the hold point, but the turn angle limit restricts + # the maximum distance of the backtrack while maintaining the direction of + # the flight plan. + self.threat_zone = coalition.opponent.threat_zone.all + self.home = ShapelyPoint(home.x, home.y) + + self.join = ShapelyPoint(join.x, join.y) + + self.join_bubble = self.join.buffer(coalition.doctrine.push_distance.meters) + + join_to_target_distance = join.distance_to_point(target) + self.target_bubble = ShapelyPoint(target.x, target.y).buffer( + join_to_target_distance + ) + + self.home_bubble = self.home.buffer(coalition.doctrine.hold_distance.meters) + + excluded_zones = shapely.ops.unary_union( + [self.join_bubble, self.target_bubble, self.threat_zone] + ) + if not isinstance(excluded_zones, MultiPolygon): + excluded_zones = MultiPolygon([excluded_zones]) + self.excluded_zones = excluded_zones + + join_heading = ip.heading_between_point(join) + + # Arbitrarily large since this is later constrained by the map boundary, and + # we'll be picking a location close to the IP anyway. Just used to avoid real + # distance calculations to project to the map edge. + large_distance = nautical_miles(400).meters + turn_limit = 40 + join_limit_ccw = join.point_from_heading( + join_heading - turn_limit, large_distance + ) + join_limit_cw = join.point_from_heading( + join_heading + turn_limit, large_distance + ) + + join_direction_limit_wedge = Polygon( + [ + (join.x, join.y), + (join_limit_ccw.x, join_limit_ccw.y), + (join_limit_cw.x, join_limit_cw.y), + ] + ) + + permissible_zones = ( + coalition.nav_mesh.map_bounds(theater) + .intersection(join_direction_limit_wedge) + .difference(self.excluded_zones) + .difference(self.home_bubble) + ) + if not isinstance(permissible_zones, MultiPolygon): + permissible_zones = MultiPolygon([permissible_zones]) + self.permissible_zones = permissible_zones + self.preferred_lines = self.home_bubble.boundary.difference(self.excluded_zones) + + def find_best_hold_point(self) -> Point: + if self.preferred_lines.is_empty: + hold, _ = shapely.ops.nearest_points(self.permissible_zones, self.home) + else: + hold, _ = shapely.ops.nearest_points(self.preferred_lines, self.join) + return Point(hold.x, hold.y) diff --git a/game/flightplan/ipzonegeometry.py b/game/flightplan/ipzonegeometry.py index 92e647c2..a909cf03 100644 --- a/game/flightplan/ipzonegeometry.py +++ b/game/flightplan/ipzonegeometry.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING import shapely.ops from dcs import Point -from shapely.geometry import Point as ShapelyPoint +from shapely.geometry import Point as ShapelyPoint, MultiPolygon from game.utils import nautical_miles, meters @@ -81,10 +81,14 @@ class IpZoneGeometry: # the home bubble. self.permissible_zone = self.ip_bubble - self.safe_zone = self.permissible_zone.difference( + safe_zones = self.permissible_zone.difference( self.threat_zone.buffer(attack_distance_buffer.meters) ) + if not isinstance(safe_zones, MultiPolygon): + safe_zones = MultiPolygon([safe_zones]) + self.safe_zones = safe_zones + def _unsafe_ip(self) -> ShapelyPoint: unthreatened_home_zone = self.home_bubble.difference(self.threat_zone) if unthreatened_home_zone.is_empty: @@ -104,10 +108,10 @@ class IpZoneGeometry: def _safe_ip(self) -> ShapelyPoint: # We have a zone of possible IPs that are safe, close enough, and in range. Pick # the IP in the zone that's closest to the target. - return shapely.ops.nearest_points(self.safe_zone, self.home)[0] + return shapely.ops.nearest_points(self.safe_zones, self.home)[0] def find_best_ip(self) -> Point: - if self.safe_zone.is_empty: + if self.safe_zones.is_empty: ip = self._unsafe_ip() else: ip = self._safe_ip() diff --git a/game/flightplan/joinzonegeometry.py b/game/flightplan/joinzonegeometry.py index 39c7d640..48cff780 100644 --- a/game/flightplan/joinzonegeometry.py +++ b/game/flightplan/joinzonegeometry.py @@ -4,7 +4,12 @@ from typing import TYPE_CHECKING import shapely.ops from dcs import Point -from shapely.geometry import Point as ShapelyPoint, Polygon +from shapely.geometry import ( + Point as ShapelyPoint, + Polygon, + MultiPolygon, + MultiLineString, +) from game.theater import ConflictTheater from game.utils import nautical_miles @@ -51,10 +56,14 @@ class JoinZoneGeometry: self.home_bubble = self.home.buffer(min_distance_from_home.meters) - self.excluded_zone = shapely.ops.unary_union( - [self.home_bubble, self.ip_bubble, self.target_bubble, self.threat_zone] + excluded_zones = shapely.ops.unary_union( + [self.ip_bubble, self.target_bubble, self.threat_zone] ) + if not isinstance(excluded_zones, MultiPolygon): + excluded_zones = MultiPolygon([excluded_zones]) + self.excluded_zones = excluded_zones + ip_heading = target.heading_between_point(ip) # Arbitrarily large since this is later constrained by the map boundary, and @@ -73,12 +82,14 @@ class JoinZoneGeometry: ] ) - self.permissible_line = ( - coalition.nav_mesh.map_bounds(theater) - .intersection(ip_direction_limit_wedge) - .intersection(self.excluded_zone.boundary) - ) + permissible_lines = ip_direction_limit_wedge.intersection( + self.excluded_zones.boundary + ).difference(self.home_bubble) + + if not isinstance(permissible_lines, MultiLineString): + permissible_lines = MultiLineString([permissible_lines]) + self.permissible_lines = permissible_lines def find_best_join_point(self) -> Point: - join, _ = shapely.ops.nearest_points(self.permissible_line, self.home) + join, _ = shapely.ops.nearest_points(self.permissible_lines, self.home) return Point(join.x, join.y) diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py index 5c1c0820..4f28e4cf 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -20,7 +20,7 @@ from dcs.unit import Unit from shapely.geometry import Point as ShapelyPoint from game.data.doctrine import Doctrine -from game.flightplan import IpZoneGeometry, JoinZoneGeometry +from game.flightplan import IpZoneGeometry, JoinZoneGeometry, HoldZoneGeometry from game.theater import ( Airfield, ControlPoint, @@ -975,31 +975,6 @@ class FlightPlanBuilder: WaypointBuilder.perturb(join_point), ) - def legacy_package_waypoints_impl(self, ingress_point: Point) -> None: - from gen.ato import PackageWaypoints - - join_point = self._rendezvous_point(ingress_point) - self.package.waypoints = PackageWaypoints( - WaypointBuilder.perturb(join_point), - ingress_point, - WaypointBuilder.perturb(join_point), - ) - - def safe_points_between(self, a: Point, b: Point) -> Iterator[Point]: - for point in self.coalition.nav_mesh.shortest_path(a, b)[1:-1]: - if not self.threat_zones.threatened(point): - yield point - - def preferred_join_point(self, ingress_point: Point) -> Optional[Point]: - # Use non-threatened points along the path to the target as the join point. We - # may need to try more than one in the event that the close non-threatened - # points are closer than the ingress point itself. - for join_point in self.safe_points_between( - ingress_point, self.package_airfield().position - ): - return join_point - return None - def generate_strike(self, flight: Flight) -> StrikeFlightPlan: """Generates a strike flight plan. @@ -1675,48 +1650,10 @@ class FlightPlanBuilder: origin = flight.departure.position target = self.package.target.position join = self.package.waypoints.join - origin_to_join = origin.distance_to_point(join) - if meters(origin_to_join) < self.doctrine.push_distance: - # If the origin airfield is closer to the join point, than the minimum push - # distance. Plan the hold point such that it retreats from the origin - # airfield. - return join.point_from_heading( - target.heading_between_point(origin), self.doctrine.push_distance.meters - ) - - heading_to_join = origin.heading_between_point(join) - hold_point = origin.point_from_heading( - heading_to_join, self.doctrine.push_distance.meters - ) - hold_distance = meters(hold_point.distance_to_point(join)) - if hold_distance >= self.doctrine.push_distance: - # Hold point is between the origin airfield and the join point and - # spaced sufficiently. - return hold_point - - # The hold point is between the origin airfield and the join point, but - # the distance between the hold point and the join point is too short. - # Bend the hold point out to extend the distance while maintaining the - # minimum distance from the origin airfield to keep the AI flying - # properly. - origin_to_join = origin.distance_to_point(join) - cos_theta = ( - self.doctrine.hold_distance.meters ** 2 - + origin_to_join ** 2 - - self.doctrine.join_distance.meters ** 2 - ) / (2 * self.doctrine.hold_distance.meters * origin_to_join) - try: - theta = math.acos(cos_theta) - except ValueError: - # No solution that maintains hold and join distances. Extend the - # hold point away from the target. - return origin.point_from_heading( - target.heading_between_point(origin), self.doctrine.hold_distance.meters - ) - - return origin.point_from_heading( - heading_to_join - theta, self.doctrine.hold_distance.meters - ) + ip = self.package.waypoints.ingress + return HoldZoneGeometry( + target, origin, ip, join, self.coalition, self.theater + ).find_best_hold_point() # TODO: Make a model for the waypoint builder and use that in the UI. def generate_rtb_waypoint( @@ -1779,59 +1716,6 @@ class FlightPlanBuilder: lead_time=lead_time, ) - def _retreating_rendezvous_point(self, attack_transition: Point) -> Point: - """Creates a rendezvous point that retreats from the origin airfield.""" - return attack_transition.point_from_heading( - self.package.target.position.heading_between_point( - self.package_airfield().position - ), - self.doctrine.join_distance.meters, - ) - - def _advancing_rendezvous_point(self, attack_transition: Point) -> Point: - """Creates a rendezvous point that advances toward the target.""" - heading = self._heading_to_package_airfield(attack_transition) - return attack_transition.point_from_heading( - heading, -self.doctrine.join_distance.meters - ) - - def _rendezvous_should_retreat(self, attack_transition: Point) -> bool: - transition_target_distance = attack_transition.distance_to_point( - self.package.target.position - ) - origin_target_distance = self._distance_to_package_airfield( - self.package.target.position - ) - - # If the origin point is closer to the target than the ingress point, - # the rendezvous point should be positioned in a position that retreats - # from the origin airfield. - return origin_target_distance < transition_target_distance - - def _rendezvous_point(self, attack_transition: Point) -> Point: - """Returns the position of the rendezvous point. - - Args: - attack_transition: The ingress or target point for this rendezvous. - """ - if self._rendezvous_should_retreat(attack_transition): - return self._retreating_rendezvous_point(attack_transition) - return self._advancing_rendezvous_point(attack_transition) - - def _ingress_point(self, heading: float) -> Point: - return self.package.target.position.point_from_heading( - heading - 180, self.doctrine.max_ingress_distance.meters - ) - - def _target_heading_to_package_airfield(self) -> float: - return self._heading_to_package_airfield(self.package.target.position) - - def _heading_to_package_airfield(self, point: Point) -> float: - return self.package_airfield().position.heading_between_point(point) - - def _distance_to_package_airfield(self, point: Point) -> float: - return self.package_airfield().position.distance_to_point(point) - def package_airfield(self) -> ControlPoint: # We'll always have a package, but if this is being planned via the UI # it could be the first flight in the package. diff --git a/qt_ui/widgets/map/mapmodel.py b/qt_ui/widgets/map/mapmodel.py index 09bc6022..e8a75298 100644 --- a/qt_ui/widgets/map/mapmodel.py +++ b/qt_ui/widgets/map/mapmodel.py @@ -8,11 +8,17 @@ from PySide2.QtCore import Property, QObject, Signal, Slot from dcs import Point from dcs.unit import Unit from dcs.vehicles import vehicle_map -from shapely.geometry import LineString, Point as ShapelyPoint, Polygon, MultiPolygon +from shapely.geometry import ( + LineString, + Point as ShapelyPoint, + Polygon, + MultiPolygon, + MultiLineString, +) from game import Game from game.dcs.groundunittype import GroundUnitType -from game.flightplan import JoinZoneGeometry +from game.flightplan import JoinZoneGeometry, HoldZoneGeometry from game.navmesh import NavMesh, NavMeshPoly from game.profiling import logged_duration from game.theater import ( @@ -89,6 +95,12 @@ def shapely_line_to_leaflet_points( return [theater.point_to_ll(Point(x, y)).as_list() for x, y in line.coords] +def shapely_lines_to_leaflet_points( + lines: MultiLineString, theater: ConflictTheater +) -> list[list[LeafletLatLon]]: + return [shapely_line_to_leaflet_points(l, theater) for l in lines.geoms] + + class ControlPointJs(QObject): nameChanged = Signal() blueChanged = Signal() @@ -802,36 +814,36 @@ class IpZonesJs(QObject): homeBubbleChanged = Signal() ipBubbleChanged = Signal() permissibleZoneChanged = Signal() - safeZoneChanged = Signal() + safeZonesChanged = Signal() def __init__( self, - home_bubble: list[LeafletPoly], - ip_bubble: list[LeafletPoly], - permissible_zone: list[LeafletPoly], - safe_zone: list[LeafletPoly], + home_bubble: LeafletPoly, + ip_bubble: LeafletPoly, + permissible_zone: LeafletPoly, + safe_zones: list[LeafletPoly], ) -> None: super().__init__() self._home_bubble = home_bubble self._ip_bubble = ip_bubble self._permissible_zone = permissible_zone - self._safe_zone = safe_zone + self._safe_zones = safe_zones @Property(list, notify=homeBubbleChanged) - def homeBubble(self) -> list[LeafletPoly]: + def homeBubble(self) -> LeafletPoly: return self._home_bubble @Property(list, notify=ipBubbleChanged) - def ipBubble(self) -> list[LeafletPoly]: + def ipBubble(self) -> LeafletPoly: return self._ip_bubble @Property(list, notify=permissibleZoneChanged) - def permissibleZone(self) -> list[LeafletPoly]: + def permissibleZone(self) -> LeafletPoly: return self._permissible_zone - @Property(list, notify=safeZoneChanged) - def safeZone(self) -> list[LeafletPoly]: - return self._safe_zone + @Property(list, notify=safeZonesChanged) + def safeZones(self) -> list[LeafletPoly]: + return self._safe_zones @classmethod def empty(cls) -> IpZonesJs: @@ -845,10 +857,10 @@ class IpZonesJs(QObject): home = flight.departure geometry = IpZoneGeometry(target.position, home.position, game.blue) return IpZonesJs( - shapely_to_leaflet_polys(geometry.home_bubble, game.theater), - shapely_to_leaflet_polys(geometry.ip_bubble, game.theater), - shapely_to_leaflet_polys(geometry.permissible_zone, game.theater), - shapely_to_leaflet_polys(geometry.safe_zone, game.theater), + shapely_poly_to_leaflet_points(geometry.home_bubble, game.theater), + shapely_poly_to_leaflet_points(geometry.ip_bubble, game.theater), + shapely_poly_to_leaflet_points(geometry.permissible_zone, game.theater), + shapely_to_leaflet_polys(geometry.safe_zones, game.theater), ) @@ -856,43 +868,43 @@ class JoinZonesJs(QObject): homeBubbleChanged = Signal() targetBubbleChanged = Signal() ipBubbleChanged = Signal() - excludedZoneChanged = Signal() - permissibleLineChanged = Signal() + excludedZonesChanged = Signal() + permissibleLinesChanged = Signal() def __init__( self, - home_bubble: list[LeafletPoly], - target_bubble: list[LeafletPoly], - ip_bubble: list[LeafletPoly], - excluded_zone: list[LeafletPoly], - permissible_line: list[LeafletLatLon], + home_bubble: LeafletPoly, + target_bubble: LeafletPoly, + ip_bubble: LeafletPoly, + excluded_zones: list[LeafletPoly], + permissible_lines: list[list[LeafletLatLon]], ) -> None: super().__init__() self._home_bubble = home_bubble self._target_bubble = target_bubble self._ip_bubble = ip_bubble - self._excluded_zone = excluded_zone - self._permissible_line = permissible_line + self._excluded_zones = excluded_zones + self._permissible_lines = permissible_lines @Property(list, notify=homeBubbleChanged) - def homeBubble(self) -> list[LeafletPoly]: + def homeBubble(self) -> LeafletPoly: return self._home_bubble @Property(list, notify=targetBubbleChanged) - def targetBubble(self) -> list[LeafletPoly]: + def targetBubble(self) -> LeafletPoly: return self._target_bubble @Property(list, notify=ipBubbleChanged) - def ipBubble(self) -> list[LeafletPoly]: + def ipBubble(self) -> LeafletPoly: return self._ip_bubble - @Property(list, notify=excludedZoneChanged) - def excludedZone(self) -> list[LeafletPoly]: - return self._excluded_zone + @Property(list, notify=excludedZonesChanged) + def excludedZones(self) -> list[LeafletPoly]: + return self._excluded_zones - @Property(list, notify=permissibleLineChanged) - def permissibleLine(self) -> list[LeafletLatLon]: - return self._permissible_line + @Property(list, notify=permissibleLinesChanged) + def permissibleLines(self) -> list[list[LeafletLatLon]]: + return self._permissible_lines @classmethod def empty(cls) -> JoinZonesJs: @@ -911,11 +923,87 @@ class JoinZonesJs(QObject): target.position, home.position, ip, game.blue, game.theater ) return JoinZonesJs( - shapely_to_leaflet_polys(geometry.home_bubble, game.theater), - shapely_to_leaflet_polys(geometry.target_bubble, game.theater), - shapely_to_leaflet_polys(geometry.ip_bubble, game.theater), - shapely_to_leaflet_polys(geometry.excluded_zone, game.theater), - shapely_line_to_leaflet_points(geometry.permissible_line, game.theater), + shapely_poly_to_leaflet_points(geometry.home_bubble, game.theater), + shapely_poly_to_leaflet_points(geometry.target_bubble, game.theater), + shapely_poly_to_leaflet_points(geometry.ip_bubble, game.theater), + shapely_to_leaflet_polys(geometry.excluded_zones, game.theater), + shapely_lines_to_leaflet_points(geometry.permissible_lines, game.theater), + ) + + +class HoldZonesJs(QObject): + homeBubbleChanged = Signal() + targetBubbleChanged = Signal() + joinBubbleChanged = Signal() + excludedZonesChanged = Signal() + permissibleZonesChanged = Signal() + permissibleLinesChanged = Signal() + + def __init__( + self, + home_bubble: LeafletPoly, + target_bubble: LeafletPoly, + join_bubble: LeafletPoly, + excluded_zones: list[LeafletPoly], + permissible_zones: list[LeafletPoly], + permissible_lines: list[list[LeafletLatLon]], + ) -> None: + super().__init__() + self._home_bubble = home_bubble + self._target_bubble = target_bubble + self._join_bubble = join_bubble + self._excluded_zones = excluded_zones + self._permissible_zones = permissible_zones + self._permissible_lines = permissible_lines + + @Property(list, notify=homeBubbleChanged) + def homeBubble(self) -> LeafletPoly: + return self._home_bubble + + @Property(list, notify=targetBubbleChanged) + def targetBubble(self) -> LeafletPoly: + return self._target_bubble + + @Property(list, notify=joinBubbleChanged) + def joinBubble(self) -> LeafletPoly: + return self._join_bubble + + @Property(list, notify=excludedZonesChanged) + def excludedZones(self) -> list[LeafletPoly]: + return self._excluded_zones + + @Property(list, notify=permissibleZonesChanged) + def permissibleZones(self) -> list[LeafletPoly]: + return self._permissible_zones + + @Property(list, notify=permissibleLinesChanged) + def permissibleLines(self) -> list[list[LeafletLatLon]]: + return self._permissible_lines + + @classmethod + def empty(cls) -> HoldZonesJs: + return HoldZonesJs([], [], [], [], [], []) + + @classmethod + def for_flight(cls, flight: Flight, game: Game) -> HoldZonesJs: + if not ENABLE_EXPENSIVE_DEBUG_TOOLS: + return JoinZonesJs.empty() + target = flight.package.target + home = flight.departure + if flight.package.waypoints is None: + return HoldZonesJs.empty() + ip = flight.package.waypoints.ingress + join = flight.package.waypoints.join + geometry = HoldZoneGeometry( + target.position, home.position, ip, join, game.blue, game.theater + ) + return HoldZonesJs( + shapely_poly_to_leaflet_points(geometry.home_bubble, game.theater), + shapely_poly_to_leaflet_points(geometry.target_bubble, game.theater), + shapely_poly_to_leaflet_points(geometry.join_bubble, game.theater), + shapely_to_leaflet_polys(geometry.excluded_zones, game.theater), + shapely_to_leaflet_polys(geometry.permissible_zones, game.theater), + [], # shapely_to_leaflet_polys(geometry.permissible_lines, game.theater), ) @@ -934,6 +1022,7 @@ class MapModel(QObject): unculledZonesChanged = Signal() ipZonesChanged = Signal() joinZonesChanged = Signal() + holdZonesChanged = Signal() def __init__(self, game_model: GameModel) -> None: super().__init__() @@ -952,6 +1041,7 @@ class MapModel(QObject): self._unculled_zones = [] self._ip_zones = IpZonesJs.empty() self._join_zones = JoinZonesJs.empty() + self._hold_zones = HoldZonesJs.empty() self._selected_flight_index: Optional[Tuple[int, int]] = None GameUpdateSignal.get_instance().game_loaded.connect(self.on_game_load) GameUpdateSignal.get_instance().flight_paths_changed.connect(self.reset_atos) @@ -1067,11 +1157,14 @@ class MapModel(QObject): if selected_flight is None: self._ip_zones = IpZonesJs.empty() self._join_zones = JoinZonesJs.empty() + self._hold_zones = HoldZonesJs.empty() else: self._ip_zones = IpZonesJs.for_flight(selected_flight, self.game) self._join_zones = JoinZonesJs.for_flight(selected_flight, self.game) + self._hold_zones = HoldZonesJs.for_flight(selected_flight, self.game) self.ipZonesChanged.emit() self.joinZonesChanged.emit() + self.holdZonesChanged.emit() @Property(list, notify=flightsChanged) def flights(self) -> List[FlightJs]: @@ -1208,6 +1301,10 @@ class MapModel(QObject): def joinZones(self) -> JoinZonesJs: return self._join_zones + @Property(HoldZonesJs, notify=holdZonesChanged) + def holdZones(self) -> HoldZonesJs: + return self._hold_zones + @property def game(self) -> Game: if self.game_model.game is None: diff --git a/resources/ui/map/map.js b/resources/ui/map/map.js index 87e4ca7e..7ac1dac2 100644 --- a/resources/ui/map/map.js +++ b/resources/ui/map/map.js @@ -198,8 +198,10 @@ const exclusionZones = L.layerGroup(); const seaZones = L.layerGroup(); const unculledZones = L.layerGroup(); +const noWaypointZones = L.layerGroup(); const ipZones = L.layerGroup(); -const joinZones = L.layerGroup().addTo(map); +const joinZones = L.layerGroup(); +const holdZones = L.layerGroup().addTo(map); const debugControlGroups = { "Blue Threat Zones": { @@ -231,8 +233,10 @@ const debugControlGroups = { if (ENABLE_EXPENSIVE_DEBUG_TOOLS) { debugControlGroups["Waypoint Zones"] = { + None: noWaypointZones, "IP Zones": ipZones, "Join Zones": joinZones, + "Hold Zones": holdZones, }; } @@ -310,6 +314,7 @@ new QWebChannel(qt.webChannelTransport, function (channel) { game.unculledZonesChanged.connect(drawUnculledZones); game.ipZonesChanged.connect(drawIpZones); game.joinZonesChanged.connect(drawJoinZones); + game.holdZonesChanged.connect(drawHoldZones); }); function recenterMap(center) { @@ -1014,11 +1019,13 @@ function drawIpZones() { interactive: false, }).addTo(ipZones); - L.polygon(game.ipZones.safeZone, { - color: Colors.Green, - fillOpacity: 0.1, - interactive: false, - }).addTo(ipZones); + for (const zone of game.ipZones.safeZones) { + L.polygon(zone, { + color: Colors.Green, + fillOpacity: 0.1, + interactive: false, + }).addTo(ipZones); + } } function drawJoinZones() { @@ -1042,17 +1049,59 @@ function drawJoinZones() { interactive: false, }).addTo(joinZones); - L.polygon(game.joinZones.excludedZone, { - color: "#ffa500", - fillOpacity: 0.2, - stroke: false, - interactive: false, - }).addTo(joinZones); + for (const zone of game.joinZones.excludedZones) { + L.polygon(zone, { + color: "#ffa500", + fillOpacity: 0.2, + stroke: false, + interactive: false, + }).addTo(joinZones); + } - L.polyline(game.joinZones.permissibleLine, { - color: Colors.Green, + for (const line of game.joinZones.permissibleLines) { + L.polyline(line, { + color: Colors.Green, + interactive: false, + }).addTo(joinZones); + } +} + +function drawHoldZones() { + holdZones.clearLayers(); + + L.polygon(game.holdZones.homeBubble, { + color: Colors.Highlight, + fillOpacity: 0.1, interactive: false, - }).addTo(joinZones); + }).addTo(holdZones); + + L.polygon(game.holdZones.targetBubble, { + color: Colors.Highlight, + fillOpacity: 0.1, + interactive: false, + }).addTo(holdZones); + + L.polygon(game.holdZones.joinBubble, { + color: Colors.Highlight, + fillOpacity: 0.1, + interactive: false, + }).addTo(holdZones); + + for (const zone of game.holdZones.excludedZones) { + L.polygon(zone, { + color: "#ffa500", + fillOpacity: 0.2, + stroke: false, + interactive: false, + }).addTo(holdZones); + } + + for (const zone of game.holdZones.permissibleZones) { + L.polygon(zone, { + color: Colors.Green, + interactive: false, + }).addTo(holdZones); + } } function drawInitialMap() { @@ -1068,6 +1117,7 @@ function drawInitialMap() { drawUnculledZones(); drawIpZones(); drawJoinZones(); + drawHoldZones(); } function clearAllLayers() { From ee77516716f0a8ef43e0339e44ba3c553dd345c1 Mon Sep 17 00:00:00 2001 From: Mustang-25 <72566076+Mustang-25@users.noreply.github.com> Date: Fri, 16 Jul 2021 00:17:20 -0700 Subject: [PATCH 04/42] Replace TGP with SPJ for JF-17 CAP/SEAD. Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1422. --- resources/customized_payloads/JF-17.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/customized_payloads/JF-17.lua b/resources/customized_payloads/JF-17.lua index 8e135655..b4c0e4b5 100644 --- a/resources/customized_payloads/JF-17.lua +++ b/resources/customized_payloads/JF-17.lua @@ -77,7 +77,7 @@ local unitPayloads = { ["num"] = 3, }, [3] = { - ["CLSID"] = "DIS_WMD7", + ["CLSID"] = "DIS_SPJ_POD", ["num"] = 4, }, [4] = { @@ -107,7 +107,7 @@ local unitPayloads = { ["name"] = "SEAD", ["pylons"] = { [1] = { - ["CLSID"] = "DIS_WMD7", + ["CLSID"] = "DIS_SPJ_POD", ["num"] = 4, }, [2] = { From 1b640f40dca364f25c2b5ce0582eb50a85bc5fd4 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Fri, 16 Jul 2021 00:26:42 -0700 Subject: [PATCH 05/42] Fix map issues when debugging tools are disabled. --- resources/ui/map/map.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/resources/ui/map/map.js b/resources/ui/map/map.js index 7ac1dac2..831701a9 100644 --- a/resources/ui/map/map.js +++ b/resources/ui/map/map.js @@ -1001,6 +1001,10 @@ function drawUnculledZones() { function drawIpZones() { ipZones.clearLayers(); + if (!ENABLE_EXPENSIVE_DEBUG_TOOLS) { + return; + } + L.polygon(game.ipZones.homeBubble, { color: Colors.Highlight, fillOpacity: 0.1, @@ -1031,6 +1035,10 @@ function drawIpZones() { function drawJoinZones() { joinZones.clearLayers(); + if (!ENABLE_EXPENSIVE_DEBUG_TOOLS) { + return; + } + L.polygon(game.joinZones.homeBubble, { color: Colors.Highlight, fillOpacity: 0.1, @@ -1069,6 +1077,10 @@ function drawJoinZones() { function drawHoldZones() { holdZones.clearLayers(); + if (!ENABLE_EXPENSIVE_DEBUG_TOOLS) { + return; + } + L.polygon(game.holdZones.homeBubble, { color: Colors.Highlight, fillOpacity: 0.1, From e5c0fc92ecb8d8911d3d4a515f3d7cd8dce82e4e Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Fri, 16 Jul 2021 01:06:31 -0700 Subject: [PATCH 06/42] Don't reload weapon data if already loaded. --- game/data/weapons.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/game/data/weapons.py b/game/data/weapons.py index 5d0b0dd1..6f2889ec 100644 --- a/game/data/weapons.py +++ b/game/data/weapons.py @@ -181,6 +181,8 @@ class WeaponGroup: @classmethod def load_all(cls) -> None: + if cls._loaded: + return seen_clsids: set[str] = set() for group in cls._each_weapon_group(): cls.register(group) From 04a346678c566008de1ce872aa1a0091b0be358f Mon Sep 17 00:00:00 2001 From: Magnus Wolffelt Date: Fri, 16 Jul 2021 23:08:14 +0200 Subject: [PATCH 07/42] Add situational temperature and pressure variation. Now varies by: * Season * Theater * Weather * Time of day --- game/theater/conflicttheater.py | 85 ++++++++++++++++++++++ game/utils.py | 12 ++++ game/weather.py | 124 ++++++++++++++++++++++++-------- 3 files changed, 192 insertions(+), 29 deletions(-) diff --git a/game/theater/conflicttheater.py b/game/theater/conflicttheater.py index e0f4d69a..43cd2c9d 100644 --- a/game/theater/conflicttheater.py +++ b/game/theater/conflicttheater.py @@ -497,6 +497,17 @@ class ReferencePoint: image_coordinates: Point +@dataclass(frozen=True) +class SeasonalConditions: + # Units are inHg and degrees Celsius + # Future improvement: add clouds/precipitation + summer_avg_pressure: float + winter_avg_pressure: float + summer_avg_temperature: float + winter_avg_temperature: float + temperature_day_night_difference: float + + class ConflictTheater: terrain: Terrain @@ -719,6 +730,10 @@ class ConflictTheater: MizCampaignLoader(directory / miz, t).populate_theater() return t + @property + def seasonal_conditions(self) -> SeasonalConditions: + raise NotImplementedError + @property def projection_parameters(self) -> TransverseMercator: raise NotImplementedError @@ -748,6 +763,16 @@ class CaucasusTheater(ConflictTheater): "night": (0, 5), } + @property + def seasonal_conditions(self) -> SeasonalConditions: + return SeasonalConditions( + summer_avg_pressure=30.02, # TODO: More science + winter_avg_pressure=29.72, # TODO: More science + summer_avg_temperature=22.5, + winter_avg_temperature=3.0, + temperature_day_night_difference=6.0, + ) + @property def projection_parameters(self) -> TransverseMercator: from .caucasus import PARAMETERS @@ -770,6 +795,16 @@ class PersianGulfTheater(ConflictTheater): "night": (0, 5), } + @property + def seasonal_conditions(self) -> SeasonalConditions: + return SeasonalConditions( + summer_avg_pressure=29.98, # TODO: More science + winter_avg_pressure=29.80, # TODO: More science + summer_avg_temperature=32.5, + winter_avg_temperature=15.0, + temperature_day_night_difference=2.0, + ) + @property def projection_parameters(self) -> TransverseMercator: from .persiangulf import PARAMETERS @@ -792,6 +827,16 @@ class NevadaTheater(ConflictTheater): "night": (0, 5), } + @property + def seasonal_conditions(self) -> SeasonalConditions: + return SeasonalConditions( + summer_avg_pressure=30.02, # TODO: More science + winter_avg_pressure=29.72, # TODO: More science + summer_avg_temperature=31.5, + winter_avg_temperature=5.0, + temperature_day_night_difference=6.0, + ) + @property def projection_parameters(self) -> TransverseMercator: from .nevada import PARAMETERS @@ -814,6 +859,16 @@ class NormandyTheater(ConflictTheater): "night": (0, 5), } + @property + def seasonal_conditions(self) -> SeasonalConditions: + return SeasonalConditions( + summer_avg_pressure=30.02, # TODO: More science + winter_avg_pressure=29.72, # TODO: More science + summer_avg_temperature=20.0, + winter_avg_temperature=0.0, + temperature_day_night_difference=5.0, + ) + @property def projection_parameters(self) -> TransverseMercator: from .normandy import PARAMETERS @@ -836,6 +891,16 @@ class TheChannelTheater(ConflictTheater): "night": (0, 5), } + @property + def seasonal_conditions(self) -> SeasonalConditions: + return SeasonalConditions( + summer_avg_pressure=30.02, # TODO: More science + winter_avg_pressure=29.72, # TODO: More science + summer_avg_temperature=20.0, + winter_avg_temperature=0.0, + temperature_day_night_difference=5.0, + ) + @property def projection_parameters(self) -> TransverseMercator: from .thechannel import PARAMETERS @@ -858,6 +923,16 @@ class SyriaTheater(ConflictTheater): "night": (0, 5), } + @property + def seasonal_conditions(self) -> SeasonalConditions: + return SeasonalConditions( + summer_avg_pressure=29.98, # TODO: More science + winter_avg_pressure=29.86, # TODO: More science + summer_avg_temperature=28.5, + winter_avg_temperature=10.0, + temperature_day_night_difference=8.0, + ) + @property def projection_parameters(self) -> TransverseMercator: from .syria import PARAMETERS @@ -877,6 +952,16 @@ class MarianaIslandsTheater(ConflictTheater): "night": (0, 5), } + @property + def seasonal_conditions(self) -> SeasonalConditions: + return SeasonalConditions( + summer_avg_pressure=30.02, # TODO: More science + winter_avg_pressure=29.82, # TODO: More science + summer_avg_temperature=28.0, + winter_avg_temperature=27.0, + temperature_day_night_difference=1.0, + ) + @property def projection_parameters(self) -> TransverseMercator: from .marianaislands import PARAMETERS diff --git a/game/utils.py b/game/utils.py index 2370c56f..291e098b 100644 --- a/game/utils.py +++ b/game/utils.py @@ -189,3 +189,15 @@ def pairwise(iterable: Iterable[Any]) -> Iterable[tuple[Any, Any]]: a, b = itertools.tee(iterable) next(b, None) return zip(a, b) + + +def interpolate(value1: float, value2: float, factor: float, clamp: bool) -> float: + """Inerpolate between two values, factor 0-1""" + interpolated = value1 + (value2 - value1) * factor + + if clamp: + bigger_value = max(value1, value2) + smaller_value = min(value1, value2) + return min(bigger_value, max(smaller_value, interpolated)) + else: + return interpolated diff --git a/game/weather.py b/game/weather.py index fae1d5a0..ae31fa7f 100644 --- a/game/weather.py +++ b/game/weather.py @@ -11,10 +11,11 @@ from dcs.cloud_presets import Clouds as PydcsClouds from dcs.weather import CloudPreset, Weather as PydcsWeather, Wind from game.settings import Settings -from game.utils import Distance, meters +from game.utils import Distance, meters, interpolate if TYPE_CHECKING: from game.theater import ConflictTheater + from game.theater.conflicttheater import SeasonalConditions class TimeOfDay(Enum): @@ -71,15 +72,56 @@ class Fog: class Weather: - def __init__(self) -> None: + def __init__( + self, + seasonal_conditions: SeasonalConditions, + day: datetime.date, + time_of_day: TimeOfDay, + ) -> None: # Future improvement: Use theater, day and time of day # to get a more realistic conditions - self.atmospheric = self.generate_atmospheric() + self.atmospheric = self.generate_atmospheric( + seasonal_conditions, day, time_of_day + ) self.clouds = self.generate_clouds() self.fog = self.generate_fog() self.wind = self.generate_wind() - def generate_atmospheric(self) -> AtmosphericConditions: + def generate_atmospheric( + self, + seasonal_conditions: SeasonalConditions, + day: datetime.date, + time_of_day: TimeOfDay, + ) -> AtmosphericConditions: + pressure = self.interpolate_summer_winter( + seasonal_conditions.summer_avg_pressure, + seasonal_conditions.winter_avg_pressure, + day, + ) + temperature = self.interpolate_summer_winter( + seasonal_conditions.summer_avg_temperature, + seasonal_conditions.winter_avg_temperature, + day, + ) + + if time_of_day == TimeOfDay.Day: + temperature += seasonal_conditions.temperature_day_night_difference / 2 + if time_of_day == TimeOfDay.Night: + temperature -= seasonal_conditions.temperature_day_night_difference / 2 + pressure += self.pressure_adjustment + temperature += self.temperature_adjustment + conditions = AtmosphericConditions( + qnh_inches_mercury=self.random_pressure(pressure), + temperature_celsius=self.random_temperature(temperature), + ) + return conditions + + @property + def pressure_adjustment(self) -> float: + raise NotImplementedError + + @property + def temperature_adjustment(self) -> float: raise NotImplementedError def generate_clouds(self) -> Optional[Clouds]: @@ -126,7 +168,7 @@ class Weather: SAFE_MIN = 28.4 SAFE_MAX = 30.9 # Use normalvariate to get normal distribution, more realistic than uniform - pressure = random.normalvariate(average_pressure, 0.2) + pressure = random.normalvariate(average_pressure, 0.1) return max(SAFE_MIN, min(SAFE_MAX, pressure)) @staticmethod @@ -136,17 +178,29 @@ class Weather: SAFE_MIN = -12 SAFE_MAX = 49 # Use normalvariate to get normal distribution, more realistic than uniform - temperature = random.normalvariate(average_temperature, 4) + temperature = random.normalvariate(average_temperature, 2) temperature = round(temperature) return max(SAFE_MIN, min(SAFE_MAX, temperature)) + @staticmethod + def interpolate_summer_winter( + summer_value: float, winter_value: float, day: datetime.date + ) -> float: + day_of_year = day.timetuple().tm_yday + day_of_year_peak_summer = 183 + distance_from_peak_summer = abs(-day_of_year_peak_summer + day_of_year) + winter_factor = distance_from_peak_summer / day_of_year_peak_summer + return interpolate(summer_value, winter_value, winter_factor, clamp=True) + class ClearSkies(Weather): - def generate_atmospheric(self) -> AtmosphericConditions: - return AtmosphericConditions( - qnh_inches_mercury=self.random_pressure(29.96), - temperature_celsius=self.random_temperature(22), - ) + @property + def pressure_adjustment(self) -> float: + return 0.22 + + @property + def temperature_adjustment(self) -> float: + return 3.0 def generate_clouds(self) -> Optional[Clouds]: return None @@ -159,11 +213,13 @@ class ClearSkies(Weather): class Cloudy(Weather): - def generate_atmospheric(self) -> AtmosphericConditions: - return AtmosphericConditions( - qnh_inches_mercury=self.random_pressure(29.90), - temperature_celsius=self.random_temperature(20), - ) + @property + def pressure_adjustment(self) -> float: + return 0.0 + + @property + def temperature_adjustment(self) -> float: + return 0.0 def generate_clouds(self) -> Optional[Clouds]: return Clouds.random_preset(rain=False) @@ -177,11 +233,13 @@ class Cloudy(Weather): class Raining(Weather): - def generate_atmospheric(self) -> AtmosphericConditions: - return AtmosphericConditions( - qnh_inches_mercury=self.random_pressure(29.70), - temperature_celsius=self.random_temperature(16), - ) + @property + def pressure_adjustment(self) -> float: + return -0.22 + + @property + def temperature_adjustment(self) -> float: + return -3.0 def generate_clouds(self) -> Optional[Clouds]: return Clouds.random_preset(rain=True) @@ -195,11 +253,13 @@ class Raining(Weather): class Thunderstorm(Weather): - def generate_atmospheric(self) -> AtmosphericConditions: - return AtmosphericConditions( - qnh_inches_mercury=self.random_pressure(29.60), - temperature_celsius=self.random_temperature(15), - ) + @property + def pressure_adjustment(self) -> float: + return 0.1 + + @property + def temperature_adjustment(self) -> float: + return -3.0 def generate_clouds(self) -> Optional[Clouds]: return Clouds( @@ -233,7 +293,7 @@ class Conditions: return cls( time_of_day=time_of_day, start_time=_start_time, - weather=cls.generate_weather(), + weather=cls.generate_weather(theater.seasonal_conditions, day, time_of_day), ) @classmethod @@ -259,7 +319,13 @@ class Conditions: return datetime.datetime.combine(day, time) @classmethod - def generate_weather(cls) -> Weather: + def generate_weather( + cls, + seasonal_conditions: SeasonalConditions, + day: datetime.date, + time_of_day: TimeOfDay, + ) -> Weather: + # Future improvement: use seasonal weights for theaters chances = { Thunderstorm: 1, Raining: 20, @@ -269,4 +335,4 @@ class Conditions: weather_type = random.choices( list(chances.keys()), weights=list(chances.values()) )[0] - return weather_type() + return weather_type(seasonal_conditions, day, time_of_day) From 771c74ee7590d698e7538f836e9a7d57c3c01480 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Fri, 16 Jul 2021 15:07:14 -0700 Subject: [PATCH 08/42] Fill out weapon data for AIM-9s. --- resources/weapons/a2a-missiles/AIM-120B-2X.yaml | 3 ++- resources/weapons/a2a-missiles/AIM-7E.yaml | 1 + resources/weapons/a2a-missiles/AIM-9L-2X.yaml | 8 ++++++++ resources/weapons/a2a-missiles/AIM-9L.yaml | 11 +++++++++++ resources/weapons/a2a-missiles/AIM-9M-2X.yaml | 7 +++++++ resources/weapons/a2a-missiles/AIM-9M.yaml | 13 +++++++++++++ resources/weapons/a2a-missiles/AIM-9P-2X.yaml | 6 ++++++ resources/weapons/a2a-missiles/AIM-9P.yaml | 6 ++++++ resources/weapons/a2a-missiles/AIM-9P5-2X.yaml | 6 ++++++ resources/weapons/a2a-missiles/AIM-9P5.yaml | 6 ++++++ resources/weapons/a2a-missiles/AIM-9X-2X.yaml | 5 +++++ resources/weapons/a2a-missiles/AIM-9X.yaml | 9 +++++++++ 12 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 resources/weapons/a2a-missiles/AIM-9L-2X.yaml create mode 100644 resources/weapons/a2a-missiles/AIM-9L.yaml create mode 100644 resources/weapons/a2a-missiles/AIM-9M-2X.yaml create mode 100644 resources/weapons/a2a-missiles/AIM-9M.yaml create mode 100644 resources/weapons/a2a-missiles/AIM-9P-2X.yaml create mode 100644 resources/weapons/a2a-missiles/AIM-9P.yaml create mode 100644 resources/weapons/a2a-missiles/AIM-9P5-2X.yaml create mode 100644 resources/weapons/a2a-missiles/AIM-9P5.yaml create mode 100644 resources/weapons/a2a-missiles/AIM-9X-2X.yaml create mode 100644 resources/weapons/a2a-missiles/AIM-9X.yaml diff --git a/resources/weapons/a2a-missiles/AIM-120B-2X.yaml b/resources/weapons/a2a-missiles/AIM-120B-2X.yaml index 562fcb0f..736cb20a 100644 --- a/resources/weapons/a2a-missiles/AIM-120B-2X.yaml +++ b/resources/weapons/a2a-missiles/AIM-120B-2X.yaml @@ -1,5 +1,6 @@ name: 2xAIM-120B year: 1994 -fallback: AIM-7MH +# If we've run out of doubles, start over with the singles. +fallback: AIM-120C clsids: - "LAU-115_2*LAU-127_AIM-120B" diff --git a/resources/weapons/a2a-missiles/AIM-7E.yaml b/resources/weapons/a2a-missiles/AIM-7E.yaml index 16e60733..f4231011 100644 --- a/resources/weapons/a2a-missiles/AIM-7E.yaml +++ b/resources/weapons/a2a-missiles/AIM-7E.yaml @@ -1,5 +1,6 @@ name: AIM-7E year: 1963 +fallback: AIM-9X clsids: - "{AIM-7E}" - "{LAU-115 - AIM-7E}" diff --git a/resources/weapons/a2a-missiles/AIM-9L-2X.yaml b/resources/weapons/a2a-missiles/AIM-9L-2X.yaml new file mode 100644 index 00000000..30734034 --- /dev/null +++ b/resources/weapons/a2a-missiles/AIM-9L-2X.yaml @@ -0,0 +1,8 @@ +name: 2xAIM-9L +year: 1977 +# If we've run out of doubles, start over with the singles. +fallback: AIM-9X +clsids: + - "LAU-105_2*AIM-9L" + - "LAU-115_2*LAU-127_AIM-9L" + - "{F4-2-AIM9L}" diff --git a/resources/weapons/a2a-missiles/AIM-9L.yaml b/resources/weapons/a2a-missiles/AIM-9L.yaml new file mode 100644 index 00000000..b06c6fe9 --- /dev/null +++ b/resources/weapons/a2a-missiles/AIM-9L.yaml @@ -0,0 +1,11 @@ +name: AIM-9L +year: 1977 +clsids: + - "{AIM-9L}" + - "LAU-105_1*AIM-9L_L" + - "LAU-105_1*AIM-9L_R" + - "LAU-115_LAU-127_AIM-9L" + - "LAU-115_LAU-127_AIM-9L_R" + - "LAU-127_AIM-9L" + - "{LAU-138 wtip - AIM-9L}" + - "{LAU-7 - AIM-9L}" diff --git a/resources/weapons/a2a-missiles/AIM-9M-2X.yaml b/resources/weapons/a2a-missiles/AIM-9M-2X.yaml new file mode 100644 index 00000000..d985f95a --- /dev/null +++ b/resources/weapons/a2a-missiles/AIM-9M-2X.yaml @@ -0,0 +1,7 @@ +name: 2xAIM-9M +year: 1982 +fallback: 2xAIM-9P5 +clsids: + - "{DB434044-F5D0-4F1F-9BA9-B73027E18DD3}" + - "LAU-115_2*LAU-127_AIM-9M" + - "{9DDF5297-94B9-42FC-A45E-6E316121CD85}" diff --git a/resources/weapons/a2a-missiles/AIM-9M.yaml b/resources/weapons/a2a-missiles/AIM-9M.yaml new file mode 100644 index 00000000..ee82d32f --- /dev/null +++ b/resources/weapons/a2a-missiles/AIM-9M.yaml @@ -0,0 +1,13 @@ +name: AIM-9M +year: 1982 +fallback: AIM-9P5 +clsids: + - "{6CEB49FC-DED8-4DED-B053-E1F033FF72D3}" + - "LAU-105_1*AIM-9M_L" + - "LAU-105_1*AIM-9M_R" + - "LAU-115_LAU-127_AIM-9M" + - "LAU-115_LAU-127_AIM-9M_R" + - "LAU-127_AIM-9M" + - "{LAU-138 wtip - AIM-9M}" + - "{LAU-7 - AIM-9M}" + - "{AIM-9M-ON-ADAPTER}" diff --git a/resources/weapons/a2a-missiles/AIM-9P-2X.yaml b/resources/weapons/a2a-missiles/AIM-9P-2X.yaml new file mode 100644 index 00000000..5625d97e --- /dev/null +++ b/resources/weapons/a2a-missiles/AIM-9P-2X.yaml @@ -0,0 +1,6 @@ +name: 2xAIM-9P +year: 1978 +fallback: 2xAIM-9L +clsids: + - "{3C0745ED-8B0B-42eb-B907-5BD5C1717447}" + - "{773675AB-7C29-422f-AFD8-32844A7B7F17}" diff --git a/resources/weapons/a2a-missiles/AIM-9P.yaml b/resources/weapons/a2a-missiles/AIM-9P.yaml new file mode 100644 index 00000000..52a1b092 --- /dev/null +++ b/resources/weapons/a2a-missiles/AIM-9P.yaml @@ -0,0 +1,6 @@ +name: AIM-9P +year: 1978 +fallback: AIM-9L +clsids: + - "{9BFD8C90-F7AE-4e90-833B-BFD0CED0E536}" + - "{AIM-9P-ON-ADAPTER}" diff --git a/resources/weapons/a2a-missiles/AIM-9P5-2X.yaml b/resources/weapons/a2a-missiles/AIM-9P5-2X.yaml new file mode 100644 index 00000000..9b35d4b3 --- /dev/null +++ b/resources/weapons/a2a-missiles/AIM-9P5-2X.yaml @@ -0,0 +1,6 @@ +name: 2xAIM-9P5 +year: 1980 +fallback: 2xAIM-9P +clsids: + - "LAU-105_2*AIM-9P5" + - "{F4-2-AIM9P5}" diff --git a/resources/weapons/a2a-missiles/AIM-9P5.yaml b/resources/weapons/a2a-missiles/AIM-9P5.yaml new file mode 100644 index 00000000..0da82ebf --- /dev/null +++ b/resources/weapons/a2a-missiles/AIM-9P5.yaml @@ -0,0 +1,6 @@ +name: AIM-9P5 +year: 1980 +fallback: AIM-9P +clsids: + - "{AIM-9P5}" + - "{AIM-9P5-ON-ADAPTER}" diff --git a/resources/weapons/a2a-missiles/AIM-9X-2X.yaml b/resources/weapons/a2a-missiles/AIM-9X-2X.yaml new file mode 100644 index 00000000..3496f279 --- /dev/null +++ b/resources/weapons/a2a-missiles/AIM-9X-2X.yaml @@ -0,0 +1,5 @@ +name: 2xAIM-9X +year: 2003 +fallback: 2xAIM-9M +clsids: + - "LAU-115_2*LAU-127_AIM-9X" diff --git a/resources/weapons/a2a-missiles/AIM-9X.yaml b/resources/weapons/a2a-missiles/AIM-9X.yaml new file mode 100644 index 00000000..bec1ddc0 --- /dev/null +++ b/resources/weapons/a2a-missiles/AIM-9X.yaml @@ -0,0 +1,9 @@ +name: AIM-9X +year: 2003 +fallback: AIM-9M +clsids: + - "{5CE2FF2A-645A-4197-B48D-8720AC69394F}" + - "LAU-115_LAU-127_AIM-9X" + - "LAU-115_LAU-127_AIM-9X_R" + - "LAU-127_AIM-9X" + - "{AIM-9X-ON-ADAPTER}" From bb46d00f22794446cc8708c1cfebf19419743584 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Fri, 16 Jul 2021 15:22:11 -0700 Subject: [PATCH 09/42] Add weapon data for R-77, R-27, and R-24. --- resources/weapons/a2a-missiles/R-24R.yaml | 4 ++++ resources/weapons/a2a-missiles/R-24T.yaml | 4 ++++ resources/weapons/a2a-missiles/R-27ER.yaml | 5 +++++ resources/weapons/a2a-missiles/R-27ET.yaml | 5 +++++ resources/weapons/a2a-missiles/R-27R.yaml | 5 +++++ resources/weapons/a2a-missiles/R-27T.yaml | 5 +++++ resources/weapons/a2a-missiles/R-77.yaml | 6 ++++++ 7 files changed, 34 insertions(+) create mode 100644 resources/weapons/a2a-missiles/R-24R.yaml create mode 100644 resources/weapons/a2a-missiles/R-24T.yaml create mode 100644 resources/weapons/a2a-missiles/R-27ER.yaml create mode 100644 resources/weapons/a2a-missiles/R-27ET.yaml create mode 100644 resources/weapons/a2a-missiles/R-27R.yaml create mode 100644 resources/weapons/a2a-missiles/R-27T.yaml create mode 100644 resources/weapons/a2a-missiles/R-77.yaml diff --git a/resources/weapons/a2a-missiles/R-24R.yaml b/resources/weapons/a2a-missiles/R-24R.yaml new file mode 100644 index 00000000..0865bfe2 --- /dev/null +++ b/resources/weapons/a2a-missiles/R-24R.yaml @@ -0,0 +1,4 @@ +name: R-24R +year: 1981 +clsids: + - "{CCF898C9-5BC7-49A4-9D1E-C3ED3D5166A1}" diff --git a/resources/weapons/a2a-missiles/R-24T.yaml b/resources/weapons/a2a-missiles/R-24T.yaml new file mode 100644 index 00000000..f5f64531 --- /dev/null +++ b/resources/weapons/a2a-missiles/R-24T.yaml @@ -0,0 +1,4 @@ +name: R-24T +year: 1981 +clsids: + - "{6980735A-44CC-4BB9-A1B5-591532F1DC69}" diff --git a/resources/weapons/a2a-missiles/R-27ER.yaml b/resources/weapons/a2a-missiles/R-27ER.yaml new file mode 100644 index 00000000..f3f56749 --- /dev/null +++ b/resources/weapons/a2a-missiles/R-27ER.yaml @@ -0,0 +1,5 @@ +name: R-27ER +year: 1983 +fallback: R-27R +clsids: + - "{E8069896-8435-4B90-95C0-01A03AE6E400}" diff --git a/resources/weapons/a2a-missiles/R-27ET.yaml b/resources/weapons/a2a-missiles/R-27ET.yaml new file mode 100644 index 00000000..c304bb76 --- /dev/null +++ b/resources/weapons/a2a-missiles/R-27ET.yaml @@ -0,0 +1,5 @@ +name: R-27ET +year: 1986 +fallback: R-27T +clsids: + - "{B79C379A-9E87-4E50-A1EE-7F7E29C2E87A}" diff --git a/resources/weapons/a2a-missiles/R-27R.yaml b/resources/weapons/a2a-missiles/R-27R.yaml new file mode 100644 index 00000000..3f9edc94 --- /dev/null +++ b/resources/weapons/a2a-missiles/R-27R.yaml @@ -0,0 +1,5 @@ +name: R-27R +year: 1983 +fallback: R-24R +clsids: + - "{9B25D316-0434-4954-868F-D51DB1A38DF0}" diff --git a/resources/weapons/a2a-missiles/R-27T.yaml b/resources/weapons/a2a-missiles/R-27T.yaml new file mode 100644 index 00000000..c9232ef6 --- /dev/null +++ b/resources/weapons/a2a-missiles/R-27T.yaml @@ -0,0 +1,5 @@ +name: R-27T +year: 1983 +fallback: R-24T +clsids: + - "{88DAC840-9F75-4531-8689-B46E64E42E53}" diff --git a/resources/weapons/a2a-missiles/R-77.yaml b/resources/weapons/a2a-missiles/R-77.yaml new file mode 100644 index 00000000..13eb3074 --- /dev/null +++ b/resources/weapons/a2a-missiles/R-77.yaml @@ -0,0 +1,6 @@ +name: R-77 +year: 2002 +fallback: R-27ER +clsids: + - "{B4C01D60-A8A3-4237-BD72-CA7655BC0FE9}" + - "{B4C01D60-A8A3-4237-BD72-CA7655BC0FEC}" From aa3d644f97478af6b6027439f311a6256d79bb17 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Fri, 16 Jul 2021 15:27:32 -0700 Subject: [PATCH 10/42] Prevent empty cheek stations for the Hornet. This is a bit of a hack that makes the TGPs fall back to AIM-120s. It works okay because this only applies to a few cases: The A-10 gets an empty pylon. That's fine. Maybe later we can add multiple fallback paths and depth-first-search through them so that that pylon could carry bombs instead. The Viper has no replacemnt for that station. The jammer goes on the other fuselage station, the HTS isn't a replacement, and we don't have LANTIRN for the Viper. No weapons can be fit to those stations. What this helps is the Hornet, where any Gulf War scenario ends up with an empty cheek station because we don't have the NITE HAWK to fall back to. In this case we can instead fall back through the air-to-air missiles to fill the station. --- resources/weapons/pods/atflir.yaml | 3 +++ resources/weapons/pods/litening.yaml | 9 +++++++++ 2 files changed, 12 insertions(+) diff --git a/resources/weapons/pods/atflir.yaml b/resources/weapons/pods/atflir.yaml index 64ef6833..3733a299 100644 --- a/resources/weapons/pods/atflir.yaml +++ b/resources/weapons/pods/atflir.yaml @@ -1,4 +1,7 @@ name: AN/ASQ-228 ATFLIR year: 2003 +# A bit of a hack, but fixes the common case where the Hornet cheek station is +# empty because no TGP is available. +fallback: AIM-120C clsids: - "{AN_ASQ_228}" diff --git a/resources/weapons/pods/litening.yaml b/resources/weapons/pods/litening.yaml index 0ea9db08..e6fd5141 100644 --- a/resources/weapons/pods/litening.yaml +++ b/resources/weapons/pods/litening.yaml @@ -1,5 +1,14 @@ name: AN/AAQ-28 LITENING year: 1999 +# A bit of a hack, but fixes the common case where the Hornet cheek station is +# empty because no TGP is available. For the Viper this will have no effect +# because missiles can't be put on that station, but for the Viper an empty +# pylon is the correct replacement for a TGP anyway (the jammer goes on the +# other fuselage station, HTS isn't a good replacement, and we don't have +# LANTIRN for the Viper). +# +# For the A-10 an empty pylon is also fine. +fallback: AIM-120C clsids: - "{A111396E-D3E8-4b9c-8AC9-2432489304D5}" - "{AAQ-28_LEFT}" From b733e6855b543134dab8ffb037ecdb9829649503 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Fri, 16 Jul 2021 15:48:12 -0700 Subject: [PATCH 11/42] Add SLAM/SLAM-ER weapon data. --- resources/weapons/standoff/AGM-84E.yaml | 6 ++++++ resources/weapons/standoff/AGM-84H.yaml | 5 +++++ 2 files changed, 11 insertions(+) create mode 100644 resources/weapons/standoff/AGM-84E.yaml create mode 100644 resources/weapons/standoff/AGM-84H.yaml diff --git a/resources/weapons/standoff/AGM-84E.yaml b/resources/weapons/standoff/AGM-84E.yaml new file mode 100644 index 00000000..f1041fb9 --- /dev/null +++ b/resources/weapons/standoff/AGM-84E.yaml @@ -0,0 +1,6 @@ +name: AGM-84E SLAM +year: 1990 +fallback: AGM-62 Walleye II +clsids: + - "{AF42E6DF-9A60-46D8-A9A0-1708B241AADB}" + - "{AGM_84E}" diff --git a/resources/weapons/standoff/AGM-84H.yaml b/resources/weapons/standoff/AGM-84H.yaml new file mode 100644 index 00000000..38d1bf02 --- /dev/null +++ b/resources/weapons/standoff/AGM-84H.yaml @@ -0,0 +1,5 @@ +name: AGM-84H SLAM-ER +year: 2000 +fallback: AGM-84E SLAM +clsids: + - "{AGM_84H}" From 11c2d4ab2591e7264ad144ce9df3fd5cd848d82b Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Fri, 16 Jul 2021 16:23:22 -0700 Subject: [PATCH 12/42] Add JDAMs and their fallbacks. Hornet should be compatible with 1990 campaigns now. Air-to-ground weapon restrictions are less interesting for AI aircraft so I haven't covered *all* the variants here (the >2 variants of each carried by the B1 and such). --- resources/weapons/bombs/GBU-10-2X.yaml | 4 ++++ resources/weapons/bombs/GBU-10.yaml | 6 ++++++ resources/weapons/bombs/GBU-12-2X.yaml | 13 +++++++++++++ resources/weapons/bombs/GBU-12.yaml | 6 ++++++ resources/weapons/bombs/GBU-16-2X.yaml | 6 ++++++ resources/weapons/bombs/GBU-16.yaml | 6 ++++++ resources/weapons/bombs/GBU-24.yaml | 7 +++++++ resources/weapons/bombs/GBU-31V1B.yaml | 5 +++++ resources/weapons/bombs/GBU-31V2B.yaml | 5 +++++ resources/weapons/bombs/GBU-31V3B.yaml | 5 +++++ resources/weapons/bombs/GBU-31V4B.yaml | 5 +++++ resources/weapons/bombs/GBU-32V2B.yaml | 5 +++++ resources/weapons/bombs/GBU-38-2X.yaml | 8 ++++++++ resources/weapons/bombs/GBU-38.yaml | 5 +++++ 14 files changed, 86 insertions(+) create mode 100644 resources/weapons/bombs/GBU-10-2X.yaml create mode 100644 resources/weapons/bombs/GBU-10.yaml create mode 100644 resources/weapons/bombs/GBU-12-2X.yaml create mode 100644 resources/weapons/bombs/GBU-12.yaml create mode 100644 resources/weapons/bombs/GBU-16-2X.yaml create mode 100644 resources/weapons/bombs/GBU-16.yaml create mode 100644 resources/weapons/bombs/GBU-24.yaml create mode 100644 resources/weapons/bombs/GBU-31V1B.yaml create mode 100644 resources/weapons/bombs/GBU-31V2B.yaml create mode 100644 resources/weapons/bombs/GBU-31V3B.yaml create mode 100644 resources/weapons/bombs/GBU-31V4B.yaml create mode 100644 resources/weapons/bombs/GBU-32V2B.yaml create mode 100644 resources/weapons/bombs/GBU-38-2X.yaml create mode 100644 resources/weapons/bombs/GBU-38.yaml diff --git a/resources/weapons/bombs/GBU-10-2X.yaml b/resources/weapons/bombs/GBU-10-2X.yaml new file mode 100644 index 00000000..0f261926 --- /dev/null +++ b/resources/weapons/bombs/GBU-10-2X.yaml @@ -0,0 +1,4 @@ +name: 2xGBU-10 +year: 1976 +clsids: + - "{62BE78B1-9258-48AE-B882-279534C0D278}" diff --git a/resources/weapons/bombs/GBU-10.yaml b/resources/weapons/bombs/GBU-10.yaml new file mode 100644 index 00000000..36e30965 --- /dev/null +++ b/resources/weapons/bombs/GBU-10.yaml @@ -0,0 +1,6 @@ +name: GBU-10 +year: 1976 +clsids: + - "DIS_GBU_10" + - "{BRU-32 GBU-10}" + - "{51F9AAE5-964F-4D21-83FB-502E3BFE5F8A}" diff --git a/resources/weapons/bombs/GBU-12-2X.yaml b/resources/weapons/bombs/GBU-12-2X.yaml new file mode 100644 index 00000000..282667c7 --- /dev/null +++ b/resources/weapons/bombs/GBU-12-2X.yaml @@ -0,0 +1,13 @@ +name: 2xGBU-12 +year: 1976 +clsids: + - "{M2KC_RAFAUT_GBU12}" + - "{BRU33_2X_GBU-12}" + - "DIS_GBU_12_DUAL_GDJ_II19_L" + - "DIS_GBU_12_DUAL_GDJ_II19_R" + - "{TER_9A_2L*GBU-12}" + - "{TER_9A_2R*GBU-12}" + - "{89D000B0-0360-461A-AD83-FB727E2ABA98}" + - "{BRU-42_2xGBU-12_right}" + - "{BRU-42_2*GBU-12_LEFT}" + - "{BRU-42_2*GBU-12_RIGHT}" diff --git a/resources/weapons/bombs/GBU-12.yaml b/resources/weapons/bombs/GBU-12.yaml new file mode 100644 index 00000000..3e9500b3 --- /dev/null +++ b/resources/weapons/bombs/GBU-12.yaml @@ -0,0 +1,6 @@ +name: GBU-12 +year: 1976 +clsids: + - "DIS_GBU_12" + - "{BRU-32 GBU-12}" + - "{DB769D48-67D7-42ED-A2BE-108D566C8B1E}" diff --git a/resources/weapons/bombs/GBU-16-2X.yaml b/resources/weapons/bombs/GBU-16-2X.yaml new file mode 100644 index 00000000..22a48d70 --- /dev/null +++ b/resources/weapons/bombs/GBU-16-2X.yaml @@ -0,0 +1,6 @@ +name: 2xGBU-16 +year: 1976 +clsids: + - "{BRU33_2X_GBU-16}" + - "{BRU-42_2*GBU-16_LEFT}" + - "{BRU-42_2*GBU-16_RIGHT}" diff --git a/resources/weapons/bombs/GBU-16.yaml b/resources/weapons/bombs/GBU-16.yaml new file mode 100644 index 00000000..c31f360e --- /dev/null +++ b/resources/weapons/bombs/GBU-16.yaml @@ -0,0 +1,6 @@ +name: GBU-16 +year: 1976 +clsids: + - "DIS_GBU_16" + - "{BRU-32 GBU-16}" + - "{0D33DDAE-524F-4A4E-B5B8-621754FE3ADE}" diff --git a/resources/weapons/bombs/GBU-24.yaml b/resources/weapons/bombs/GBU-24.yaml new file mode 100644 index 00000000..b9c8fd14 --- /dev/null +++ b/resources/weapons/bombs/GBU-24.yaml @@ -0,0 +1,7 @@ +name: GBU-24 +year: 1986 +fallback: GBU-10 +clsids: + - "{BRU-32 GBU-24}" + - "{34759BBC-AF1E-4AEE-A581-498FF7A6EBCE}" + - "{GBU-24}" diff --git a/resources/weapons/bombs/GBU-31V1B.yaml b/resources/weapons/bombs/GBU-31V1B.yaml new file mode 100644 index 00000000..b08b3f34 --- /dev/null +++ b/resources/weapons/bombs/GBU-31V1B.yaml @@ -0,0 +1,5 @@ +name: GBU-31(V)1/B +year: 2001 +fallback: GBU-24 +clsids: + - "{GBU-31}" diff --git a/resources/weapons/bombs/GBU-31V2B.yaml b/resources/weapons/bombs/GBU-31V2B.yaml new file mode 100644 index 00000000..a8a55030 --- /dev/null +++ b/resources/weapons/bombs/GBU-31V2B.yaml @@ -0,0 +1,5 @@ +name: GBU-31(V)2/B +year: 2001 +fallback: GBU-24 +clsids: + - "{GBU_31_V_2B}" diff --git a/resources/weapons/bombs/GBU-31V3B.yaml b/resources/weapons/bombs/GBU-31V3B.yaml new file mode 100644 index 00000000..0f4e0843 --- /dev/null +++ b/resources/weapons/bombs/GBU-31V3B.yaml @@ -0,0 +1,5 @@ +name: GBU-31(V)3/B +year: 2001 +fallback: GBU-24 +clsids: + - "{GBU-31V3B}" diff --git a/resources/weapons/bombs/GBU-31V4B.yaml b/resources/weapons/bombs/GBU-31V4B.yaml new file mode 100644 index 00000000..04b6298a --- /dev/null +++ b/resources/weapons/bombs/GBU-31V4B.yaml @@ -0,0 +1,5 @@ +name: GBU-31(V)4/B +year: 2001 +fallback: GBU-24 +clsids: + - "{GBU_31_V_4B}" diff --git a/resources/weapons/bombs/GBU-32V2B.yaml b/resources/weapons/bombs/GBU-32V2B.yaml new file mode 100644 index 00000000..0f65cf5e --- /dev/null +++ b/resources/weapons/bombs/GBU-32V2B.yaml @@ -0,0 +1,5 @@ +name: GBU-32(V)2/B +year: 2002 +fallback: GBU-16 +clsids: + - "{GBU_32_V_2B}" diff --git a/resources/weapons/bombs/GBU-38-2X.yaml b/resources/weapons/bombs/GBU-38-2X.yaml new file mode 100644 index 00000000..48f0ac39 --- /dev/null +++ b/resources/weapons/bombs/GBU-38-2X.yaml @@ -0,0 +1,8 @@ +name: 2xGBU-38 +year: 2002 +fallback: 2xGBU-12 +clsids: + - "{BRU55_2*GBU-38}" + - "{BRU57_2*GBU-38}" + - "{BRU-42_2*GBU-38_LEFT}" + - "{BRU-42_2*GBU-38_RIGHT}" diff --git a/resources/weapons/bombs/GBU-38.yaml b/resources/weapons/bombs/GBU-38.yaml new file mode 100644 index 00000000..b02f2332 --- /dev/null +++ b/resources/weapons/bombs/GBU-38.yaml @@ -0,0 +1,5 @@ +name: GBU-38 +year: 2002 +fallback: GBU-12 +clsids: + - "{GBU-38}" From 8e977f994fd8ff57a67293552deb01f4825472b9 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Fri, 16 Jul 2021 17:43:37 -0700 Subject: [PATCH 13/42] Remove LGBs from degraded loadouts without TGPs. This only takes effect for default loadouts. Custom loadouts set from the UI will allow LGBs. In the default case there will not be buddy-lase coordination so we should take iron bombs instead. Also adds single/double Mk 83 and Mk 82 weapon data to accomodate this. --- changelog.md | 1 + game/data/weapons.py | 35 ++++++++++-- gen/flights/loadouts.py | 73 ++++++++++++++++++++------ resources/weapons/bombs/GBU-10-2X.yaml | 2 + resources/weapons/bombs/GBU-10.yaml | 2 + resources/weapons/bombs/GBU-12-2X.yaml | 2 + resources/weapons/bombs/GBU-12.yaml | 2 + resources/weapons/bombs/GBU-16-2X.yaml | 2 + resources/weapons/bombs/GBU-16.yaml | 2 + resources/weapons/bombs/GBU-24.yaml | 1 + resources/weapons/bombs/Mk-82-2X.yaml | 18 +++++++ resources/weapons/bombs/Mk-82.yaml | 11 ++++ resources/weapons/bombs/Mk-83-2X.yaml | 7 +++ resources/weapons/bombs/Mk-83.yaml | 16 ++++++ resources/weapons/pods/atflir.yaml | 1 + resources/weapons/pods/lantirn.yaml | 7 +++ resources/weapons/pods/litening.yaml | 1 + 17 files changed, 162 insertions(+), 21 deletions(-) create mode 100644 resources/weapons/bombs/Mk-82-2X.yaml create mode 100644 resources/weapons/bombs/Mk-82.yaml create mode 100644 resources/weapons/bombs/Mk-83-2X.yaml create mode 100644 resources/weapons/bombs/Mk-83.yaml create mode 100644 resources/weapons/pods/lantirn.yaml diff --git a/changelog.md b/changelog.md index 7722fcd7..ba02af20 100644 --- a/changelog.md +++ b/changelog.md @@ -5,6 +5,7 @@ Saves from 3.x are not compatible with 5.0. ## Features/Improvements * **[Campaign]** Weapon data such as fallbacks and introduction years is now moddable. Due to the new architecture to support this, the old data was not automatically migrated. +* **[Campaign]** Era-restricted loadouts will now skip LGBs when no TGP is available in the loadout. This only applies to default loadouts; buddy-lasing can be coordinated with custom loadouts. * **[Campaign AI]** Overhauled campaign AI target prioritization. This currently only affects the ordering of DEAD missions. * **[Campaign AI]** Player front line stances can now be automated. Improved stance selection for AI. * **[Campaign AI]** Reworked layout of hold, join, split, and ingress points. Should result in much shorter flight plans in general while still maintaining safe join/split/hold points. diff --git a/game/data/weapons.py b/game/data/weapons.py index 6f2889ec..8e7c86c9 100644 --- a/game/data/weapons.py +++ b/game/data/weapons.py @@ -4,6 +4,7 @@ import datetime import inspect import logging from dataclasses import dataclass, field +from enum import unique, Enum from functools import cached_property from pathlib import Path from typing import Iterator, Optional, Any, ClassVar @@ -61,7 +62,7 @@ class Weapon: duplicate = cls._by_clsid[weapon.clsid] raise ValueError( "Weapon CLSID used in more than one weapon type: " - f"{duplicate.name} and {weapon.name}" + f"{duplicate.name} and {weapon.name}: {weapon.clsid}" ) cls._by_clsid[weapon.clsid] = weapon @@ -91,6 +92,13 @@ class Weapon: fallback = fallback.fallback +@unique +class WeaponType(Enum): + LGB = "LGB" + TGP = "TGP" + UNKNOWN = "unknown" + + @dataclass(frozen=True) class WeaponGroup: """Group of "identical" weapons loaded from resources/weapons. @@ -101,7 +109,10 @@ class WeaponGroup: """ #: The name of the weapon group in the resource file. - name: str = field(compare=False) + name: str + + #: The type of the weapon group. + type: WeaponType = field(compare=False) #: The year of introduction. introduction_year: Optional[int] = field(compare=False) @@ -152,9 +163,13 @@ class WeaponGroup: with group_file_path.open(encoding="utf8") as group_file: data = yaml.safe_load(group_file) name = data["name"] + try: + weapon_type = WeaponType(data["type"]) + except KeyError: + weapon_type = WeaponType.UNKNOWN year = data.get("year") fallback_name = data.get("fallback") - group = WeaponGroup(name, year, fallback_name) + group = WeaponGroup(name, weapon_type, year, fallback_name) for clsid in data["clsids"]: weapon = Weapon(clsid, group) Weapon.register(weapon) @@ -163,7 +178,12 @@ class WeaponGroup: @classmethod def register_clean_pylon(cls) -> None: - group = WeaponGroup("Clean pylon", introduction_year=None, fallback_name=None) + group = WeaponGroup( + "Clean pylon", + type=WeaponType.UNKNOWN, + introduction_year=None, + fallback_name=None, + ) cls.register(group) weapon = Weapon("", group) Weapon.register(weapon) @@ -172,7 +192,12 @@ class WeaponGroup: @classmethod def register_unknown_weapons(cls, seen_clsids: set[str]) -> None: unknown_weapons = set(weapon_ids.keys()) - seen_clsids - group = WeaponGroup("Unknown", introduction_year=None, fallback_name=None) + group = WeaponGroup( + "Unknown", + type=WeaponType.UNKNOWN, + introduction_year=None, + fallback_name=None, + ) cls.register(group) for clsid in unknown_weapons: weapon = Weapon(clsid, group) diff --git a/gen/flights/loadouts.py b/gen/flights/loadouts.py index 826cc01a..0e3dd4d6 100644 --- a/gen/flights/loadouts.py +++ b/gen/flights/loadouts.py @@ -1,9 +1,10 @@ from __future__ import annotations import datetime -from typing import Optional, List, Iterator, TYPE_CHECKING, Mapping +from collections import Iterable +from typing import Optional, Iterator, TYPE_CHECKING, Mapping -from game.data.weapons import Weapon, Pylon +from game.data.weapons import Weapon, Pylon, WeaponType from game.dcs.aircrafttype import AircraftType if TYPE_CHECKING: @@ -30,9 +31,28 @@ class Loadout: def derive_custom(self, name: str) -> Loadout: return Loadout(name, self.pylons, self.date, is_custom=True) + @staticmethod + def _fallback_for( + weapon: Weapon, + pylon: Pylon, + date: datetime.date, + skip_types: Optional[Iterable[WeaponType]] = None, + ) -> Optional[Weapon]: + if skip_types is None: + skip_types = set() + for fallback in weapon.fallbacks: + if not pylon.can_equip(fallback): + continue + if not fallback.available_on(date): + continue + if fallback.weapon_group.type in skip_types: + continue + return fallback + return None + def degrade_for_date(self, unit_type: AircraftType, date: datetime.date) -> Loadout: if self.date is not None and self.date <= date: - return Loadout(self.name, self.pylons, self.date) + return Loadout(self.name, self.pylons, self.date, self.is_custom) new_pylons = dict(self.pylons) for pylon_number, weapon in self.pylons.items(): @@ -41,16 +61,41 @@ class Loadout: continue if not weapon.available_on(date): pylon = Pylon.for_aircraft(unit_type, pylon_number) - for fallback in weapon.fallbacks: - if not pylon.can_equip(fallback): - continue - if not fallback.available_on(date): - continue - new_pylons[pylon_number] = fallback - break - else: + fallback = self._fallback_for(weapon, pylon, date) + if fallback is None: del new_pylons[pylon_number] - return Loadout(f"{self.name} ({date.year})", new_pylons, date) + else: + new_pylons[pylon_number] = fallback + loadout = Loadout(self.name, new_pylons, date, self.is_custom) + # If this is not a custom loadout, we should replace any LGBs with iron bombs if + # the loadout lost its TGP. + # + # If the loadout was chosen explicitly by the user, assume they know what + # they're doing. They may be coordinating buddy-lase. + if not loadout.is_custom: + loadout.replace_lgbs_if_no_tgp(unit_type, date) + return loadout + + def replace_lgbs_if_no_tgp( + self, unit_type: AircraftType, date: datetime.date + ) -> None: + for weapon in self.pylons.values(): + if weapon is not None and weapon.weapon_group.type is WeaponType.TGP: + # Have a TGP. Nothing to do. + return + + new_pylons = dict(self.pylons) + for pylon_number, weapon in self.pylons.items(): + if weapon is not None and weapon.weapon_group.type is WeaponType.LGB: + pylon = Pylon.for_aircraft(unit_type, pylon_number) + fallback = self._fallback_for( + weapon, pylon, date, skip_types={WeaponType.LGB} + ) + if fallback is None: + del new_pylons[pylon_number] + else: + new_pylons[pylon_number] = fallback + self.pylons = new_pylons @classmethod def iter_for(cls, flight: Flight) -> Iterator[Loadout]: @@ -72,10 +117,6 @@ class Loadout: date=None, ) - @classmethod - def all_for(cls, flight: Flight) -> List[Loadout]: - return list(cls.iter_for(flight)) - @classmethod def default_loadout_names_for(cls, flight: Flight) -> Iterator[str]: from gen.flights.flight import FlightType diff --git a/resources/weapons/bombs/GBU-10-2X.yaml b/resources/weapons/bombs/GBU-10-2X.yaml index 0f261926..c0c51345 100644 --- a/resources/weapons/bombs/GBU-10-2X.yaml +++ b/resources/weapons/bombs/GBU-10-2X.yaml @@ -1,4 +1,6 @@ name: 2xGBU-10 +type: LGB year: 1976 +fallback: 2xMk 84 clsids: - "{62BE78B1-9258-48AE-B882-279534C0D278}" diff --git a/resources/weapons/bombs/GBU-10.yaml b/resources/weapons/bombs/GBU-10.yaml index 36e30965..4b7306d1 100644 --- a/resources/weapons/bombs/GBU-10.yaml +++ b/resources/weapons/bombs/GBU-10.yaml @@ -1,5 +1,7 @@ name: GBU-10 +type: LGB year: 1976 +fallback: Mk 84 clsids: - "DIS_GBU_10" - "{BRU-32 GBU-10}" diff --git a/resources/weapons/bombs/GBU-12-2X.yaml b/resources/weapons/bombs/GBU-12-2X.yaml index 282667c7..2cd83f89 100644 --- a/resources/weapons/bombs/GBU-12-2X.yaml +++ b/resources/weapons/bombs/GBU-12-2X.yaml @@ -1,5 +1,7 @@ name: 2xGBU-12 +type: LGB year: 1976 +fallback: 2xMk 82 clsids: - "{M2KC_RAFAUT_GBU12}" - "{BRU33_2X_GBU-12}" diff --git a/resources/weapons/bombs/GBU-12.yaml b/resources/weapons/bombs/GBU-12.yaml index 3e9500b3..85705c79 100644 --- a/resources/weapons/bombs/GBU-12.yaml +++ b/resources/weapons/bombs/GBU-12.yaml @@ -1,5 +1,7 @@ name: GBU-12 +type: LGB year: 1976 +fallback: Mk 82 clsids: - "DIS_GBU_12" - "{BRU-32 GBU-12}" diff --git a/resources/weapons/bombs/GBU-16-2X.yaml b/resources/weapons/bombs/GBU-16-2X.yaml index 22a48d70..19afd987 100644 --- a/resources/weapons/bombs/GBU-16-2X.yaml +++ b/resources/weapons/bombs/GBU-16-2X.yaml @@ -1,5 +1,7 @@ name: 2xGBU-16 +type: LGB year: 1976 +fallback: 2xMk 83 clsids: - "{BRU33_2X_GBU-16}" - "{BRU-42_2*GBU-16_LEFT}" diff --git a/resources/weapons/bombs/GBU-16.yaml b/resources/weapons/bombs/GBU-16.yaml index c31f360e..c966af60 100644 --- a/resources/weapons/bombs/GBU-16.yaml +++ b/resources/weapons/bombs/GBU-16.yaml @@ -1,5 +1,7 @@ name: GBU-16 +type: LGB year: 1976 +fallback: Mk 83 clsids: - "DIS_GBU_16" - "{BRU-32 GBU-16}" diff --git a/resources/weapons/bombs/GBU-24.yaml b/resources/weapons/bombs/GBU-24.yaml index b9c8fd14..6258584f 100644 --- a/resources/weapons/bombs/GBU-24.yaml +++ b/resources/weapons/bombs/GBU-24.yaml @@ -1,4 +1,5 @@ name: GBU-24 +type: LGB year: 1986 fallback: GBU-10 clsids: diff --git a/resources/weapons/bombs/Mk-82-2X.yaml b/resources/weapons/bombs/Mk-82-2X.yaml new file mode 100644 index 00000000..3a3d7ea7 --- /dev/null +++ b/resources/weapons/bombs/Mk-82-2X.yaml @@ -0,0 +1,18 @@ +name: 2xMk 82 +fallback: Mk 82 +clsids: + - "{M2KC_RAFAUT_MK82}" + - "{BRU33_2X_MK-82}" + - "DIS_MK_82_DUAL_GDJ_II19_L" + - "DIS_MK_82_DUAL_GDJ_II19_R" + - "{D5D51E24-348C-4702-96AF-97A714E72697}" + - "{TER_9A_2L*MK-82}" + - "{TER_9A_2R*MK-82}" + - "{BRU-42_2*Mk-82_LEFT}" + - "{BRU-42_2*Mk-82_RIGHT}" + - "{BRU42_2*MK82 RS}" + - "{BRU3242_2*MK82 RS}" + - "{PHXBRU3242_2*MK82 RS}" + - "{BRU42_2*MK82 LS}" + - "{BRU3242_2*MK82 LS}" + - "{PHXBRU3242_2*MK82 LS}" diff --git a/resources/weapons/bombs/Mk-82.yaml b/resources/weapons/bombs/Mk-82.yaml new file mode 100644 index 00000000..70733a5b --- /dev/null +++ b/resources/weapons/bombs/Mk-82.yaml @@ -0,0 +1,11 @@ +name: Mk 82 +clsids: + - "{BRU-32 MK-82}" + - "{Mk_82B}" + - "{Mk_82BT}" + - "{Mk_82P}" + - "{Mk_82PT}" + - "{Mk_82SB}" + - "{Mk_82SP}" + - "{Mk_82YT}" + - "{BCE4E030-38E9-423E-98ED-24BE3DA87C32}" diff --git a/resources/weapons/bombs/Mk-83-2X.yaml b/resources/weapons/bombs/Mk-83-2X.yaml new file mode 100644 index 00000000..4d2876ad --- /dev/null +++ b/resources/weapons/bombs/Mk-83-2X.yaml @@ -0,0 +1,7 @@ +name: 2xMk 83 +fallback: Mk 83 +clsids: + - "{BRU33_2X_MK-83}" + - "{18617C93-78E7-4359-A8CE-D754103EDF63}" + - "{BRU-42_2*Mk-83_LEFT}" + - "{BRU-42_2*Mk-83_RIGHT}" diff --git a/resources/weapons/bombs/Mk-83.yaml b/resources/weapons/bombs/Mk-83.yaml new file mode 100644 index 00000000..6df0f06e --- /dev/null +++ b/resources/weapons/bombs/Mk-83.yaml @@ -0,0 +1,16 @@ +name: Mk 83 +clsids: + - "{MAK79_MK83 1R}" + - "{MAK79_MK83 1L}" + - "{BRU-32 MK-83}" + - "{Mk_83BT}" + - "{Mk_83CT}" + - "{Mk_83P}" + - "{Mk_83PT}" + - "{BRU42_MK83 RS}" + - "{BRU3242_MK83 RS}" + - "{PHXBRU3242_MK83 RS}" + - "{7A44FF09-527C-4B7E-B42B-3F111CFE50FB}" + - "{BRU42_MK83 LS}" + - "{BRU3242_MK83 LS}" + - "{PHXBRU3242_MK83 LS}" diff --git a/resources/weapons/pods/atflir.yaml b/resources/weapons/pods/atflir.yaml index 3733a299..a33ee9ca 100644 --- a/resources/weapons/pods/atflir.yaml +++ b/resources/weapons/pods/atflir.yaml @@ -1,4 +1,5 @@ name: AN/ASQ-228 ATFLIR +type: TGP year: 2003 # A bit of a hack, but fixes the common case where the Hornet cheek station is # empty because no TGP is available. diff --git a/resources/weapons/pods/lantirn.yaml b/resources/weapons/pods/lantirn.yaml new file mode 100644 index 00000000..c9af761c --- /dev/null +++ b/resources/weapons/pods/lantirn.yaml @@ -0,0 +1,7 @@ +name: AN/AAQ-14 LANTIRN +type: TGP +year: 1990 +clsids: + - "{F14-LANTIRN-TP}" + - "{CAAC1CFD-6745-416B-AFA4-CB57414856D0}" + - "{D1744B93-2A8A-4C4D-B004-7A09CD8C8F3F}" diff --git a/resources/weapons/pods/litening.yaml b/resources/weapons/pods/litening.yaml index e6fd5141..4bee3ed6 100644 --- a/resources/weapons/pods/litening.yaml +++ b/resources/weapons/pods/litening.yaml @@ -1,4 +1,5 @@ name: AN/AAQ-28 LITENING +type: TGP year: 1999 # A bit of a hack, but fixes the common case where the Hornet cheek station is # empty because no TGP is available. For the Viper this will have no effect From d11174da21fccbf348b42212f911300fa7d09d49 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Fri, 16 Jul 2021 22:25:40 -0700 Subject: [PATCH 14/42] Stop cluttering the kneeboard with empty notes. --- gen/kneeboard.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/gen/kneeboard.py b/gen/kneeboard.py index 35aac4e3..a9c1d1c2 100644 --- a/gen/kneeboard.py +++ b/gen/kneeboard.py @@ -578,20 +578,16 @@ class NotesPage(KneeboardPage): def __init__( self, - game: "Game", + notes: str, dark_kneeboard: bool, ) -> None: - self.game = game + self.notes = notes self.dark_kneeboard = dark_kneeboard def write(self, path: Path) -> None: writer = KneeboardPageWriter(dark_theme=self.dark_kneeboard) writer.title(f"Notes") - - try: - writer.text(self.game.notes) - except AttributeError: # old saves may not have .notes ;) - writer.text("") + writer.text(self.notes) writer.write(path) @@ -663,12 +659,12 @@ class KneeboardGenerator(MissionInfoGenerator): self.mission.start_time, self.dark_kneeboard, ), - NotesPage( - self.game, - self.dark_kneeboard, - ), ] + # Only create the notes page if there are notes to show. + if notes := self.game.notes: + pages.append(NotesPage(notes, self.dark_kneeboard)) + if (target_page := self.generate_task_page(flight)) is not None: pages.append(target_page) From 28f98aed881d69c023beb875656b552f9c888bd3 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Fri, 16 Jul 2021 22:38:41 -0700 Subject: [PATCH 15/42] Migrate pressure to a typed unit. --- game/utils.py | 24 ++++++++++++++++++++++++ game/weather.py | 23 ++++++++++++++++------- gen/environmentgen.py | 3 +-- gen/kneeboard.py | 11 +++-------- gen/units.py | 16 ---------------- 5 files changed, 44 insertions(+), 33 deletions(-) delete mode 100644 gen/units.py diff --git a/game/utils.py b/game/utils.py index 291e098b..bdd849ce 100644 --- a/game/utils.py +++ b/game/utils.py @@ -16,6 +16,9 @@ KPH_TO_KNOTS = 1 / KNOTS_TO_KPH MS_TO_KPH = 3.6 KPH_TO_MS = 1 / MS_TO_KPH +INHG_TO_HPA = 33.86389 +INHG_TR_MMHG = 25.400002776728 + def heading_sum(h: int, a: int) -> int: h += a @@ -181,6 +184,27 @@ def mach(value: float, altitude: Distance) -> Speed: SPEED_OF_SOUND_AT_SEA_LEVEL = knots(661.5) +@dataclass(frozen=True, order=True) +class Pressure: + pressure_in_inches_hg: float + + @property + def inches_hg(self) -> float: + return self.pressure_in_inches_hg + + @property + def mm_hg(self) -> float: + return self.pressure_in_inches_hg * INHG_TR_MMHG + + @property + def hecto_pascals(self) -> float: + return self.pressure_in_inches_hg * INHG_TO_HPA + + +def inches_hg(value: float) -> Pressure: + return Pressure(value) + + def pairwise(iterable: Iterable[Any]) -> Iterable[tuple[Any, Any]]: """ itertools recipe diff --git a/game/weather.py b/game/weather.py index ae31fa7f..952335bd 100644 --- a/game/weather.py +++ b/game/weather.py @@ -5,13 +5,14 @@ import logging import random from dataclasses import dataclass, field from enum import Enum -from typing import Optional, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING, Any from dcs.cloud_presets import Clouds as PydcsClouds from dcs.weather import CloudPreset, Weather as PydcsWeather, Wind +from game.savecompat import has_save_compat_for from game.settings import Settings -from game.utils import Distance, meters, interpolate +from game.utils import Distance, meters, interpolate, Pressure, inches_hg if TYPE_CHECKING: from game.theater import ConflictTheater @@ -27,11 +28,19 @@ class TimeOfDay(Enum): @dataclass(frozen=True) class AtmosphericConditions: - #: Pressure at sea level in inches of mercury. - qnh_inches_mercury: float + #: Pressure at sea level. + qnh: Pressure + #: Temperature at sea level in Celcius. temperature_celsius: float + @has_save_compat_for(5) + def __setstate__(self, state: dict[str, Any]) -> None: + if "qnh" not in state: + state["qnh"] = inches_hg(state["qnh_inches_mercury"]) + del state["qnh_inches_mercury"] + self.__dict__.update(state) + @dataclass(frozen=True) class WindConditions: @@ -111,7 +120,7 @@ class Weather: pressure += self.pressure_adjustment temperature += self.temperature_adjustment conditions = AtmosphericConditions( - qnh_inches_mercury=self.random_pressure(pressure), + qnh=self.random_pressure(pressure), temperature_celsius=self.random_temperature(temperature), ) return conditions @@ -162,14 +171,14 @@ class Weather: return random.randint(100, 400) @staticmethod - def random_pressure(average_pressure: float) -> float: + def random_pressure(average_pressure: float) -> Pressure: # "Safe" constants based roughly on ME and viper altimeter. # Units are inches of mercury. SAFE_MIN = 28.4 SAFE_MAX = 30.9 # Use normalvariate to get normal distribution, more realistic than uniform pressure = random.normalvariate(average_pressure, 0.1) - return max(SAFE_MIN, min(SAFE_MAX, pressure)) + return inches_hg(max(SAFE_MIN, min(SAFE_MAX, pressure))) @staticmethod def random_temperature(average_temperature: float) -> float: diff --git a/gen/environmentgen.py b/gen/environmentgen.py index 2bc9da84..84f5bd59 100644 --- a/gen/environmentgen.py +++ b/gen/environmentgen.py @@ -3,7 +3,6 @@ from typing import Optional from dcs.mission import Mission from game.weather import Clouds, Fog, Conditions, WindConditions, AtmosphericConditions -from .units import inches_hg_to_mm_hg class EnvironmentGenerator: @@ -12,7 +11,7 @@ class EnvironmentGenerator: self.conditions = conditions def set_atmospheric(self, atmospheric: AtmosphericConditions) -> None: - self.mission.weather.qnh = inches_hg_to_mm_hg(atmospheric.qnh_inches_mercury) + self.mission.weather.qnh = atmospheric.qnh.mm_hg self.mission.weather.season_temperature = atmospheric.temperature_celsius def set_clouds(self, clouds: Optional[Clouds]) -> None: diff --git a/gen/kneeboard.py b/gen/kneeboard.py index a9c1d1c2..20fb8ca1 100644 --- a/gen/kneeboard.py +++ b/gen/kneeboard.py @@ -47,7 +47,6 @@ from .briefinggen import CommInfo, JtacInfo, MissionInfoGenerator from .flights.flight import FlightWaypoint, FlightWaypointType, FlightType from .radios import RadioFrequency from .runways import RunwayData -from .units import inches_hg_to_mm_hg, inches_hg_to_hpa if TYPE_CHECKING: from game import Game @@ -308,13 +307,9 @@ class BriefingPage(KneeboardPage): writer.text(f"Bullseye: {self.bullseye.to_lat_lon(self.theater).format_dms()}") - qnh_in_hg = "{:.2f}".format(self.weather.atmospheric.qnh_inches_mercury) - qnh_mm_hg = "{:.1f}".format( - inches_hg_to_mm_hg(self.weather.atmospheric.qnh_inches_mercury) - ) - qnh_hpa = "{:.1f}".format( - inches_hg_to_hpa(self.weather.atmospheric.qnh_inches_mercury) - ) + qnh_in_hg = f"{self.weather.atmospheric.qnh.inches_hg:.2f}" + qnh_mm_hg = f"{self.weather.atmospheric.qnh.mm_hg:.1f}" + qnh_hpa = f"{self.weather.atmospheric.qnh.hecto_pascals:.1f}" writer.text( f"Temperature: {round(self.weather.atmospheric.temperature_celsius)} °C at sea level" ) diff --git a/gen/units.py b/gen/units.py deleted file mode 100644 index 9aec8348..00000000 --- a/gen/units.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Unit conversions.""" - - -def meters_to_feet(meters: float) -> float: - """Converts meters to feet.""" - return meters * 3.28084 - - -def inches_hg_to_mm_hg(inches_hg: float) -> float: - """Converts inches mercury to millimeters mercury.""" - return inches_hg * 25.400002776728 - - -def inches_hg_to_hpa(inches_hg: float) -> float: - """Converts inches mercury to hectopascal.""" - return inches_hg * 33.86389 From f2dc95b86dc93349abe9018162410b1d3bea07f8 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Fri, 16 Jul 2021 22:43:59 -0700 Subject: [PATCH 16/42] Fix typo. --- game/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/game/utils.py b/game/utils.py index bdd849ce..39daa058 100644 --- a/game/utils.py +++ b/game/utils.py @@ -17,7 +17,7 @@ MS_TO_KPH = 3.6 KPH_TO_MS = 1 / MS_TO_KPH INHG_TO_HPA = 33.86389 -INHG_TR_MMHG = 25.400002776728 +INHG_TO_MMHG = 25.400002776728 def heading_sum(h: int, a: int) -> int: @@ -194,7 +194,7 @@ class Pressure: @property def mm_hg(self) -> float: - return self.pressure_in_inches_hg * INHG_TR_MMHG + return self.pressure_in_inches_hg * INHG_TO_MMHG @property def hecto_pascals(self) -> float: From 9bb8e00c3d3e72b1117bf6f1ec4b603129da1bc5 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Wed, 26 May 2021 23:35:46 -0700 Subject: [PATCH 17/42] Allow configuration of the air wing at game start. After completing the new game wizard but before initializing turn 0, open a dialog to allow the player to customize their air wing. With this they can remove squadrons from the game, rename them, add players, or change allowed mission types. *Adding* squadrons is not currently supported, nor is changing the squadron's livery (the data in pydcs is an arbitrary class hierarchy that can't be safely indexed by country). This only applies to the blue air wing for now. Future improvements: * Add squadron button. * Collapse disable squadrons to declutter? * Tabs on the side like the settings dialog to group by aircraft type. * Top tab bar to switch between red and blue air wings. --- game/coalition.py | 7 + game/game.py | 2 + game/squadrons.py | 12 +- game/theater/start_generator.py | 1 - qt_ui/main.py | 4 +- qt_ui/windows/AirWingConfigurationDialog.py | 220 ++++++++++++++++++++ qt_ui/windows/newgame/QNewGameWizard.py | 5 + 7 files changed, 246 insertions(+), 5 deletions(-) create mode 100644 qt_ui/windows/AirWingConfigurationDialog.py diff --git a/game/coalition.py b/game/coalition.py index 1922f3ce..01c1e2cb 100644 --- a/game/coalition.py +++ b/game/coalition.py @@ -150,6 +150,13 @@ class Coalition: # is handled correctly. self.transfers.perform_transfers() + def preinit_turn_0(self) -> None: + """Runs final Coalition initialization. + + Final initialization occurs before Game.initialize_turn runs for turn 0. + """ + self.air_wing.populate_for_turn_0() + def initialize_turn(self) -> None: """Processes coalition-specific turn initialization. diff --git a/game/game.py b/game/game.py index 6ce7b178..7125cc24 100644 --- a/game/game.py +++ b/game/game.py @@ -294,6 +294,8 @@ class Game: def begin_turn_0(self) -> None: """Initialization for the first turn of the game.""" self.turn = 0 + self.blue.preinit_turn_0() + self.red.preinit_turn_0() self.initialize_turn() def pass_turn(self, no_action: bool = False) -> None: diff --git a/game/squadrons.py b/game/squadrons.py index 3a23d4ea..45ebe7de 100644 --- a/game/squadrons.py +++ b/game/squadrons.py @@ -101,9 +101,6 @@ class Squadron: settings: Settings = field(hash=False, compare=False) def __post_init__(self) -> None: - if any(p.status is not PilotStatus.Active for p in self.pilot_pool): - raise ValueError("Squadrons can only be created with active pilots.") - self._recruit_pilots(self.settings.squadron_pilot_limit) self.auto_assignable_mission_types = set(self.mission_types) def __str__(self) -> str: @@ -181,6 +178,11 @@ class Squadron: self.current_roster.extend(new_pilots) self.available_pilots.extend(new_pilots) + def populate_for_turn_0(self) -> None: + if any(p.status is not PilotStatus.Active for p in self.pilot_pool): + raise ValueError("Squadrons can only be created with active pilots.") + self._recruit_pilots(self.settings.squadron_pilot_limit) + def replenish_lost_pilots(self) -> None: if not self.pilot_limits_enabled: return @@ -414,6 +416,10 @@ class AirWing: def squadron_at_index(self, index: int) -> Squadron: return list(self.iter_squadrons())[index] + def populate_for_turn_0(self) -> None: + for squadron in self.iter_squadrons(): + squadron.populate_for_turn_0() + def replenish(self) -> None: for squadron in self.iter_squadrons(): squadron.replenish_lost_pilots() diff --git a/game/theater/start_generator.py b/game/theater/start_generator.py index 0bf85391..aee758e9 100644 --- a/game/theater/start_generator.py +++ b/game/theater/start_generator.py @@ -123,7 +123,6 @@ class GameGenerator: GroundObjectGenerator(game, self.generator_settings).generate() game.settings.version = VERSION - game.begin_turn_0() return game def prepare_theater(self) -> None: diff --git a/qt_ui/main.py b/qt_ui/main.py index 26c5cb48..ee614287 100644 --- a/qt_ui/main.py +++ b/qt_ui/main.py @@ -246,7 +246,9 @@ def create_game( high_digit_sams=False, ), ) - return generator.generate() + game = generator.generate() + game.begin_turn_0() + return game def lint_weapon_data() -> None: diff --git a/qt_ui/windows/AirWingConfigurationDialog.py b/qt_ui/windows/AirWingConfigurationDialog.py new file mode 100644 index 00000000..fb6bdc8b --- /dev/null +++ b/qt_ui/windows/AirWingConfigurationDialog.py @@ -0,0 +1,220 @@ +import itertools +import logging +from collections import defaultdict +from typing import Optional, Callable, Iterator + +from PySide2.QtCore import ( + QItemSelectionModel, + QModelIndex, + QSize, + Qt, +) +from PySide2.QtWidgets import ( + QAbstractItemView, + QDialog, + QListView, + QVBoxLayout, + QGroupBox, + QGridLayout, + QLabel, + QWidget, + QScrollArea, + QLineEdit, + QTextEdit, + QCheckBox, + QHBoxLayout, +) + +from game import Game +from game.squadrons import Squadron, AirWing, Pilot +from gen.flights.flight import FlightType +from qt_ui.models import AirWingModel, SquadronModel +from qt_ui.windows.AirWingDialog import SquadronDelegate +from qt_ui.windows.SquadronDialog import SquadronDialog + + +class SquadronList(QListView): + """List view for displaying the air wing's squadrons.""" + + def __init__(self, air_wing_model: AirWingModel) -> None: + super().__init__() + self.air_wing_model = air_wing_model + self.dialog: Optional[SquadronDialog] = None + + self.setIconSize(QSize(91, 24)) + self.setItemDelegate(SquadronDelegate(self.air_wing_model)) + self.setModel(self.air_wing_model) + self.selectionModel().setCurrentIndex( + self.air_wing_model.index(0, 0, QModelIndex()), QItemSelectionModel.Select + ) + + # self.setIconSize(QSize(91, 24)) + self.setSelectionBehavior(QAbstractItemView.SelectItems) + self.doubleClicked.connect(self.on_double_click) + + def on_double_click(self, index: QModelIndex) -> None: + if not index.isValid(): + return + self.dialog = SquadronDialog( + SquadronModel(self.air_wing_model.squadron_at_index(index)), self + ) + self.dialog.show() + + +class AllowedMissionTypeControls(QVBoxLayout): + def __init__(self, squadron: Squadron) -> None: + super().__init__() + self.squadron = squadron + self.allowed_mission_types = set() + + self.addWidget(QLabel("Allowed mission types")) + + def make_callback(toggled_task: FlightType) -> Callable[[bool], None]: + def callback(checked: bool) -> None: + self.on_toggled(toggled_task, checked) + + return callback + + for task in FlightType: + enabled = task in squadron.mission_types + if enabled: + self.allowed_mission_types.add(task) + checkbox = QCheckBox(text=task.value) + checkbox.setChecked(enabled) + checkbox.toggled.connect(make_callback(task)) + self.addWidget(checkbox) + + self.addStretch() + + def on_toggled(self, task: FlightType, checked: bool) -> None: + if checked: + self.allowed_mission_types.add(task) + else: + self.allowed_mission_types.remove(task) + + +class SquadronConfigurationBox(QGroupBox): + def __init__(self, squadron: Squadron) -> None: + super().__init__() + self.setCheckable(True) + self.squadron = squadron + self.reset_title() + + columns = QHBoxLayout() + self.setLayout(columns) + + left_column = QVBoxLayout() + columns.addLayout(left_column) + + left_column.addWidget(QLabel("Name:")) + self.name_edit = QLineEdit(squadron.name) + self.name_edit.textChanged.connect(self.on_name_changed) + left_column.addWidget(self.name_edit) + + left_column.addWidget(QLabel("Nickname:")) + self.nickname_edit = QLineEdit(squadron.nickname) + self.nickname_edit.textChanged.connect(self.on_nickname_changed) + left_column.addWidget(self.nickname_edit) + + left_column.addWidget( + QLabel("Players (one per line, leave empty for an AI-only squadron):") + ) + players = [p for p in squadron.available_pilots if p.player] + for player in players: + squadron.available_pilots.remove(player) + self.player_list = QTextEdit("
".join(p.name for p in players)) + self.player_list.setAcceptRichText(False) + left_column.addWidget(self.player_list) + + left_column.addStretch() + + self.allowed_missions = AllowedMissionTypeControls(squadron) + columns.addLayout(self.allowed_missions) + + def on_name_changed(self, text: str) -> None: + self.squadron.name = text + self.reset_title() + + def on_nickname_changed(self, text: str) -> None: + self.squadron.nickname = text + + def reset_title(self) -> None: + self.setTitle(f"{self.squadron.name} - {self.squadron.aircraft}") + + def apply(self) -> Squadron: + player_names = self.player_list.toPlainText().splitlines() + # Prepend player pilots so they get set active first. + self.squadron.pilot_pool = [ + Pilot(n, player=True) for n in player_names + ] + self.squadron.pilot_pool + self.squadron.mission_types = tuple(self.allowed_missions.allowed_mission_types) + return self.squadron + + +class AirWingConfigurationLayout(QVBoxLayout): + def __init__(self, air_wing: AirWing) -> None: + super().__init__() + self.air_wing = air_wing + self.squadron_configs = [] + + doc_url = ( + "https://github.com/dcs-liberation/dcs_liberation/wiki/Squadrons-and-pilots" + ) + doc_label = QLabel( + "Use this opportunity to customize the squadrons available to your " + "coalition. This is your
" + "only opportunity to make changes.

" + "
" + "To accept your changes and continue, close this window.
" + "
" + "To remove a squadron from the game, uncheck the box in the title. New " + "squadrons cannot
" + "be added via the UI at this time. To add a custom squadron, see " + f'the wiki.' + ) + + doc_label.setOpenExternalLinks(True) + self.addWidget(doc_label) + for squadron in self.air_wing.iter_squadrons(): + squadron_config = SquadronConfigurationBox(squadron) + self.squadron_configs.append(squadron_config) + self.addWidget(squadron_config) + + def apply(self) -> None: + keep_squadrons = defaultdict(list) + for squadron_config in self.squadron_configs: + if squadron_config.isChecked(): + squadron = squadron_config.apply() + keep_squadrons[squadron.aircraft].append(squadron) + self.air_wing.squadrons = keep_squadrons + + +class AirWingConfigurationDialog(QDialog): + """Dialog window for air wing configuration.""" + + def __init__(self, game: Game, parent) -> None: + super().__init__(parent) + self.air_wing = game.blue.air_wing + + self.setMinimumSize(500, 800) + self.setWindowTitle(f"Air Wing Configuration") + # TODO: self.setWindowIcon() + + self.air_wing_config = AirWingConfigurationLayout(self.air_wing) + + scrolling_layout = QVBoxLayout() + scrolling_widget = QWidget() + scrolling_widget.setLayout(self.air_wing_config) + + scrolling_area = QScrollArea() + scrolling_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + scrolling_area.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn) + scrolling_area.setWidgetResizable(True) + scrolling_area.setWidget(scrolling_widget) + + scrolling_layout.addWidget(scrolling_area) + self.setLayout(scrolling_layout) + + def reject(self) -> None: + self.air_wing_config.apply() + super().reject() diff --git a/qt_ui/windows/newgame/QNewGameWizard.py b/qt_ui/windows/newgame/QNewGameWizard.py index 264f73cf..b29a4806 100644 --- a/qt_ui/windows/newgame/QNewGameWizard.py +++ b/qt_ui/windows/newgame/QNewGameWizard.py @@ -15,6 +15,7 @@ from game.theater.start_generator import GameGenerator, GeneratorSettings, ModSe from game.factions.faction import Faction from qt_ui.widgets.QLiberationCalendar import QLiberationCalendar from qt_ui.widgets.spinsliders import TenthsSpinSlider, TimeInputs, CurrencySpinner +from qt_ui.windows.AirWingConfigurationDialog import AirWingConfigurationDialog from qt_ui.windows.newgame.QCampaignList import ( Campaign, QCampaignList, @@ -125,6 +126,10 @@ class NewGameWizard(QtWidgets.QWizard): ) self.generatedGame = generator.generate() + AirWingConfigurationDialog(self.generatedGame, self).exec_() + + self.generatedGame.begin_turn_0() + super(NewGameWizard, self).accept() From adab00bc0e1656904d1484790536797cd77e3943 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Sat, 17 Jul 2021 14:31:14 -0700 Subject: [PATCH 18/42] Update changelog. --- changelog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/changelog.md b/changelog.md index ba02af20..16a2c500 100644 --- a/changelog.md +++ b/changelog.md @@ -9,6 +9,7 @@ Saves from 3.x are not compatible with 5.0. * **[Campaign AI]** Overhauled campaign AI target prioritization. This currently only affects the ordering of DEAD missions. * **[Campaign AI]** Player front line stances can now be automated. Improved stance selection for AI. * **[Campaign AI]** Reworked layout of hold, join, split, and ingress points. Should result in much shorter flight plans in general while still maintaining safe join/split/hold points. +* **[New Game Wizard]** Can now customize the player's air wing before campaign start to disable or rename squadrons. ## Fixes From 04a8040292198f821e27f2b065af42e991576749 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Sat, 17 Jul 2021 15:37:45 -0700 Subject: [PATCH 19/42] Prevent carriers from claiming most TGOs. The naval CP generators will only spawn ships, so if any of the other TGO types were closest to the CV or LHA they just would not be generated. --- changelog.md | 2 ++ game/theater/conflicttheater.py | 14 ++++++++++---- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/changelog.md b/changelog.md index 16a2c500..4b131551 100644 --- a/changelog.md +++ b/changelog.md @@ -13,6 +13,8 @@ Saves from 3.x are not compatible with 5.0. ## Fixes +* **[Campaign]** Naval control points will no longer claim ground objectives during campaign generation and prevent them from spawning. + # 4.1.0 Saves from 4.0.0 are compatible with 4.1.0. diff --git a/game/theater/conflicttheater.py b/game/theater/conflicttheater.py index 43cd2c9d..95e53ac9 100644 --- a/game/theater/conflicttheater.py +++ b/game/theater/conflicttheater.py @@ -389,8 +389,10 @@ class MizCampaignLoader: origin, list(reversed(waypoints)) ) - def objective_info(self, near: Positioned) -> Tuple[ControlPoint, Distance]: - closest = self.theater.closest_control_point(near.position) + def objective_info( + self, near: Positioned, allow_naval: bool = False + ) -> Tuple[ControlPoint, Distance]: + closest = self.theater.closest_control_point(near.position, allow_naval) distance = meters(closest.position.distance_to_point(near.position)) return closest, distance @@ -402,7 +404,7 @@ class MizCampaignLoader: ) for ship in self.ships: - closest, distance = self.objective_info(ship) + closest, distance = self.objective_info(ship, allow_naval=True) closest.preset_locations.ships.append( PointWithHeading.from_point(ship.position, ship.units[0].heading) ) @@ -644,10 +646,14 @@ class ConflictTheater: def enemy_points(self) -> List[ControlPoint]: return list(self.control_points_for(player=False)) - def closest_control_point(self, point: Point) -> ControlPoint: + def closest_control_point( + self, point: Point, allow_naval: bool = False + ) -> ControlPoint: closest = self.controlpoints[0] closest_distance = point.distance_to_point(closest.position) for control_point in self.controlpoints[1:]: + if control_point.is_fleet and not allow_naval: + continue distance = point.distance_to_point(control_point.position) if distance < closest_distance: closest = control_point From c65ac5a7cfb714bba7e0f34a92721dac6eb8d9f7 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Sat, 17 Jul 2021 15:31:52 -0700 Subject: [PATCH 20/42] Move mission range data into the aircraft type. The doctrine/task limits were capturing a reasonable average for the era, but it did a bad job for cases like the Harrier vs the Hornet, which perform similar missions but have drastically different max ranges. It also forced us into limiting CAS missions (even those flown by long range aircraft like the A-10) to 50nm since helicopters could commonly be fragged to them. This should allow us to design campaigns without needing airfields to be a max of ~50-100nm apart. --- changelog.md | 1 + game/commander/aircraftallocator.py | 18 +++++----- game/commander/missionproposals.py | 4 --- game/commander/packagebuilder.py | 2 +- game/commander/packagefulfiller.py | 1 - game/commander/tasks/packageplanningtask.py | 28 ++++------------ game/commander/tasks/primitive/aewc.py | 5 ++- game/commander/tasks/primitive/antiship.py | 12 ++----- .../commander/tasks/primitive/antishipping.py | 7 ++-- game/commander/tasks/primitive/bai.py | 7 ++-- game/commander/tasks/primitive/barcap.py | 5 ++- game/commander/tasks/primitive/cas.py | 7 ++-- .../tasks/primitive/convoyinterdiction.py | 6 ++-- game/commander/tasks/primitive/dead.py | 22 +++---------- game/commander/tasks/primitive/oca.py | 11 +++---- game/commander/tasks/primitive/refueling.py | 5 ++- game/commander/tasks/primitive/strike.py | 7 ++-- game/data/doctrine.py | 19 ++++------- game/dcs/aircrafttype.py | 19 +++++++++-- game/procurement.py | 33 +++++++------------ game/transfers.py | 4 +-- resources/units/aircraft/A-50.yaml | 1 + resources/units/aircraft/AV8BNA.yaml | 1 + resources/units/aircraft/An-26B.yaml | 1 + resources/units/aircraft/B-1B.yaml | 4 ++- resources/units/aircraft/B-52H.yaml | 4 ++- resources/units/aircraft/C-130.yaml | 1 + resources/units/aircraft/C-17A.yaml | 1 + resources/units/aircraft/E-2C.yaml | 1 + resources/units/aircraft/E-3A.yaml | 1 + resources/units/aircraft/F-14A-135-GR.yaml | 1 + resources/units/aircraft/F-14B.yaml | 1 + resources/units/aircraft/F-16A.yaml | 1 + resources/units/aircraft/F-16C_50.yaml | 1 + resources/units/aircraft/Hercules.yaml | 4 ++- resources/units/aircraft/IL-76MD.yaml | 1 + resources/units/aircraft/IL-78M.yaml | 1 + resources/units/aircraft/KC-135.yaml | 1 + resources/units/aircraft/KC130.yaml | 4 ++- resources/units/aircraft/KC135MPRS.yaml | 4 ++- resources/units/aircraft/KJ-2000.yaml | 1 + resources/units/aircraft/S-3B Tanker.yaml | 4 ++- resources/units/aircraft/Yak-40.yaml | 1 + 43 files changed, 120 insertions(+), 143 deletions(-) diff --git a/changelog.md b/changelog.md index 4b131551..e0e61174 100644 --- a/changelog.md +++ b/changelog.md @@ -9,6 +9,7 @@ Saves from 3.x are not compatible with 5.0. * **[Campaign AI]** Overhauled campaign AI target prioritization. This currently only affects the ordering of DEAD missions. * **[Campaign AI]** Player front line stances can now be automated. Improved stance selection for AI. * **[Campaign AI]** Reworked layout of hold, join, split, and ingress points. Should result in much shorter flight plans in general while still maintaining safe join/split/hold points. +* **[Campaign AI]** Auto-planning mission range limits are now specified per-aircraft. On average this means that longer range missions will now be plannable. The limit only accounts for the direct distance to the target, not the path taken. * **[New Game Wizard]** Can now customize the player's air wing before campaign start to disable or rename squadrons. ## Fixes diff --git a/game/commander/aircraftallocator.py b/game/commander/aircraftallocator.py index 16ea678a..523fad64 100644 --- a/game/commander/aircraftallocator.py +++ b/game/commander/aircraftallocator.py @@ -3,7 +3,8 @@ from typing import Optional, Tuple from game.commander.missionproposals import ProposedFlight from game.inventory import GlobalAircraftInventory from game.squadrons import AirWing, Squadron -from game.theater import ControlPoint +from game.theater import ControlPoint, MissionTarget +from game.utils import meters from gen.flights.ai_flight_planner_db import aircraft_for_task from gen.flights.closestairfields import ClosestAirfields from gen.flights.flight import FlightType @@ -25,7 +26,7 @@ class AircraftAllocator: self.is_player = is_player def find_squadron_for_flight( - self, flight: ProposedFlight + self, target: MissionTarget, flight: ProposedFlight ) -> Optional[Tuple[ControlPoint, Squadron]]: """Finds aircraft suitable for the given mission. @@ -45,17 +46,13 @@ class AircraftAllocator: on subsequent calls. If the found aircraft are not used, the caller is responsible for returning them to the inventory. """ - return self.find_aircraft_for_task(flight, flight.task) + return self.find_aircraft_for_task(target, flight, flight.task) def find_aircraft_for_task( - self, flight: ProposedFlight, task: FlightType + self, target: MissionTarget, flight: ProposedFlight, task: FlightType ) -> Optional[Tuple[ControlPoint, Squadron]]: types = aircraft_for_task(task) - airfields_in_range = self.closest_airfields.operational_airfields_within( - flight.max_distance - ) - - for airfield in airfields_in_range: + for airfield in self.closest_airfields.operational_airfields: if not airfield.is_friendly(self.is_player): continue inventory = self.global_inventory.for_control_point(airfield) @@ -64,6 +61,9 @@ class AircraftAllocator: continue if inventory.available(aircraft) < flight.num_aircraft: continue + distance_to_target = meters(target.distance_to(airfield)) + if distance_to_target > aircraft.max_mission_range: + continue # Valid location with enough aircraft available. Find a squadron to fit # the role. squadrons = self.air_wing.auto_assignable_for_task_with_type( diff --git a/game/commander/missionproposals.py b/game/commander/missionproposals.py index 2b8fc074..a13802b8 100644 --- a/game/commander/missionproposals.py +++ b/game/commander/missionproposals.py @@ -3,7 +3,6 @@ from enum import Enum, auto from typing import Optional from game.theater import MissionTarget -from game.utils import Distance from gen.flights.flight import FlightType @@ -27,9 +26,6 @@ class ProposedFlight: #: The number of aircraft required. num_aircraft: int - #: The maximum distance between the objective and the departure airfield. - max_distance: Distance - #: The type of threat this flight defends against if it is an escort. Escort #: flights will be pruned if the rest of the package is not threatened by #: the threat they defend against. If this flight is not an escort, this diff --git a/game/commander/packagebuilder.py b/game/commander/packagebuilder.py index 490e0286..da96a8e2 100644 --- a/game/commander/packagebuilder.py +++ b/game/commander/packagebuilder.py @@ -44,7 +44,7 @@ class PackageBuilder: caller should return any previously planned flights to the inventory using release_planned_aircraft. """ - assignment = self.allocator.find_squadron_for_flight(plan) + assignment = self.allocator.find_squadron_for_flight(self.package.target, plan) if assignment is None: return False airfield, squadron = assignment diff --git a/game/commander/packagefulfiller.py b/game/commander/packagefulfiller.py index d4d8352b..1005bfa9 100644 --- a/game/commander/packagefulfiller.py +++ b/game/commander/packagefulfiller.py @@ -83,7 +83,6 @@ class PackageFulfiller: missing_types.add(flight.task) purchase_order = AircraftProcurementRequest( near=mission.location, - range=flight.max_distance, task_capability=flight.task, number=flight.num_aircraft, ) diff --git a/game/commander/tasks/packageplanningtask.py b/game/commander/tasks/packageplanningtask.py index fb50af23..8e2eb8a2 100644 --- a/game/commander/tasks/packageplanningtask.py +++ b/game/commander/tasks/packageplanningtask.py @@ -59,28 +59,23 @@ class PackagePlanningTask(TheaterCommanderTask, Generic[MissionTargetT]): coalition.ato.add_package(self.package) @abstractmethod - def propose_flights(self, doctrine: Doctrine) -> None: + def propose_flights(self) -> None: ... def propose_flight( self, task: FlightType, num_aircraft: int, - max_distance: Optional[Distance], escort_type: Optional[EscortType] = None, ) -> None: - if max_distance is None: - max_distance = Distance.inf() - self.flights.append( - ProposedFlight(task, num_aircraft, max_distance, escort_type) - ) + self.flights.append(ProposedFlight(task, num_aircraft, escort_type)) @property def asap(self) -> bool: return False def fulfill_mission(self, state: TheaterState) -> bool: - self.propose_flights(state.context.coalition.doctrine) + self.propose_flights() fulfiller = PackageFulfiller( state.context.coalition, state.context.theater, @@ -92,20 +87,9 @@ class PackagePlanningTask(TheaterCommanderTask, Generic[MissionTargetT]): ) return self.package is not None - def propose_common_escorts(self, doctrine: Doctrine) -> None: - self.propose_flight( - FlightType.SEAD_ESCORT, - 2, - doctrine.mission_ranges.offensive, - EscortType.Sead, - ) - - self.propose_flight( - FlightType.ESCORT, - 2, - doctrine.mission_ranges.offensive, - EscortType.AirToAir, - ) + def propose_common_escorts(self) -> None: + self.propose_flight(FlightType.SEAD_ESCORT, 2, EscortType.Sead) + self.propose_flight(FlightType.ESCORT, 2, EscortType.AirToAir) def iter_iads_ranges( self, state: TheaterState, range_type: RangeType diff --git a/game/commander/tasks/primitive/aewc.py b/game/commander/tasks/primitive/aewc.py index 8153aac6..f9c6a7d2 100644 --- a/game/commander/tasks/primitive/aewc.py +++ b/game/commander/tasks/primitive/aewc.py @@ -4,7 +4,6 @@ from dataclasses import dataclass from game.commander.tasks.packageplanningtask import PackagePlanningTask from game.commander.theaterstate import TheaterState -from game.data.doctrine import Doctrine from game.theater import MissionTarget from gen.flights.flight import FlightType @@ -19,8 +18,8 @@ class PlanAewc(PackagePlanningTask[MissionTarget]): def apply_effects(self, state: TheaterState) -> None: state.aewc_targets.remove(self.target) - def propose_flights(self, doctrine: Doctrine) -> None: - self.propose_flight(FlightType.AEWC, 1, doctrine.mission_ranges.aewc) + def propose_flights(self) -> None: + self.propose_flight(FlightType.AEWC, 1) @property def asap(self) -> bool: diff --git a/game/commander/tasks/primitive/antiship.py b/game/commander/tasks/primitive/antiship.py index 3f85c74c..a135e1cd 100644 --- a/game/commander/tasks/primitive/antiship.py +++ b/game/commander/tasks/primitive/antiship.py @@ -5,7 +5,6 @@ from dataclasses import dataclass from game.commander.missionproposals import EscortType from game.commander.tasks.packageplanningtask import PackagePlanningTask from game.commander.theaterstate import TheaterState -from game.data.doctrine import Doctrine from game.theater.theatergroundobject import NavalGroundObject from gen.flights.flight import FlightType @@ -22,11 +21,6 @@ class PlanAntiShip(PackagePlanningTask[NavalGroundObject]): def apply_effects(self, state: TheaterState) -> None: state.eliminate_ship(self.target) - def propose_flights(self, doctrine: Doctrine) -> None: - self.propose_flight(FlightType.ANTISHIP, 2, doctrine.mission_ranges.offensive) - self.propose_flight( - FlightType.ESCORT, - 2, - doctrine.mission_ranges.offensive, - EscortType.AirToAir, - ) + def propose_flights(self) -> None: + self.propose_flight(FlightType.ANTISHIP, 2) + self.propose_flight(FlightType.ESCORT, 2, EscortType.AirToAir) diff --git a/game/commander/tasks/primitive/antishipping.py b/game/commander/tasks/primitive/antishipping.py index 303a9af1..64279d1b 100644 --- a/game/commander/tasks/primitive/antishipping.py +++ b/game/commander/tasks/primitive/antishipping.py @@ -4,7 +4,6 @@ from dataclasses import dataclass from game.commander.tasks.packageplanningtask import PackagePlanningTask from game.commander.theaterstate import TheaterState -from game.data.doctrine import Doctrine from game.transfers import CargoShip from gen.flights.flight import FlightType @@ -21,6 +20,6 @@ class PlanAntiShipping(PackagePlanningTask[CargoShip]): def apply_effects(self, state: TheaterState) -> None: state.enemy_shipping.remove(self.target) - def propose_flights(self, doctrine: Doctrine) -> None: - self.propose_flight(FlightType.ANTISHIP, 2, doctrine.mission_ranges.offensive) - self.propose_common_escorts(doctrine) + def propose_flights(self) -> None: + self.propose_flight(FlightType.ANTISHIP, 2) + self.propose_common_escorts() diff --git a/game/commander/tasks/primitive/bai.py b/game/commander/tasks/primitive/bai.py index f9d61818..4878171d 100644 --- a/game/commander/tasks/primitive/bai.py +++ b/game/commander/tasks/primitive/bai.py @@ -4,7 +4,6 @@ from dataclasses import dataclass from game.commander.tasks.packageplanningtask import PackagePlanningTask from game.commander.theaterstate import TheaterState -from game.data.doctrine import Doctrine from game.theater.theatergroundobject import VehicleGroupGroundObject from gen.flights.flight import FlightType @@ -21,6 +20,6 @@ class PlanBai(PackagePlanningTask[VehicleGroupGroundObject]): def apply_effects(self, state: TheaterState) -> None: state.eliminate_garrison(self.target) - def propose_flights(self, doctrine: Doctrine) -> None: - self.propose_flight(FlightType.BAI, 2, doctrine.mission_ranges.offensive) - self.propose_common_escorts(doctrine) + def propose_flights(self) -> None: + self.propose_flight(FlightType.BAI, 2) + self.propose_common_escorts() diff --git a/game/commander/tasks/primitive/barcap.py b/game/commander/tasks/primitive/barcap.py index 77302adf..c2dafae7 100644 --- a/game/commander/tasks/primitive/barcap.py +++ b/game/commander/tasks/primitive/barcap.py @@ -4,7 +4,6 @@ from dataclasses import dataclass from game.commander.tasks.packageplanningtask import PackagePlanningTask from game.commander.theaterstate import TheaterState -from game.data.doctrine import Doctrine from game.theater import ControlPoint from gen.flights.flight import FlightType @@ -19,5 +18,5 @@ class PlanBarcap(PackagePlanningTask[ControlPoint]): def apply_effects(self, state: TheaterState) -> None: state.barcaps_needed[self.target] -= 1 - def propose_flights(self, doctrine: Doctrine) -> None: - self.propose_flight(FlightType.BARCAP, 2, doctrine.mission_ranges.cap) + def propose_flights(self) -> None: + self.propose_flight(FlightType.BARCAP, 2) diff --git a/game/commander/tasks/primitive/cas.py b/game/commander/tasks/primitive/cas.py index 7a9997ff..c2785405 100644 --- a/game/commander/tasks/primitive/cas.py +++ b/game/commander/tasks/primitive/cas.py @@ -4,7 +4,6 @@ from dataclasses import dataclass from game.commander.tasks.packageplanningtask import PackagePlanningTask from game.commander.theaterstate import TheaterState -from game.data.doctrine import Doctrine from game.theater import FrontLine from gen.flights.flight import FlightType @@ -19,6 +18,6 @@ class PlanCas(PackagePlanningTask[FrontLine]): def apply_effects(self, state: TheaterState) -> None: state.vulnerable_front_lines.remove(self.target) - def propose_flights(self, doctrine: Doctrine) -> None: - self.propose_flight(FlightType.CAS, 2, doctrine.mission_ranges.cas) - self.propose_flight(FlightType.TARCAP, 2, doctrine.mission_ranges.cap) + def propose_flights(self) -> None: + self.propose_flight(FlightType.CAS, 2) + self.propose_flight(FlightType.TARCAP, 2) diff --git a/game/commander/tasks/primitive/convoyinterdiction.py b/game/commander/tasks/primitive/convoyinterdiction.py index 11ed4ee4..285326c7 100644 --- a/game/commander/tasks/primitive/convoyinterdiction.py +++ b/game/commander/tasks/primitive/convoyinterdiction.py @@ -21,6 +21,6 @@ class PlanConvoyInterdiction(PackagePlanningTask[Convoy]): def apply_effects(self, state: TheaterState) -> None: state.enemy_convoys.remove(self.target) - def propose_flights(self, doctrine: Doctrine) -> None: - self.propose_flight(FlightType.BAI, 2, doctrine.mission_ranges.offensive) - self.propose_common_escorts(doctrine) + def propose_flights(self) -> None: + self.propose_flight(FlightType.BAI, 2) + self.propose_common_escorts() diff --git a/game/commander/tasks/primitive/dead.py b/game/commander/tasks/primitive/dead.py index 3861908c..45da3cc3 100644 --- a/game/commander/tasks/primitive/dead.py +++ b/game/commander/tasks/primitive/dead.py @@ -5,7 +5,6 @@ from dataclasses import dataclass from game.commander.missionproposals import EscortType from game.commander.tasks.packageplanningtask import PackagePlanningTask from game.commander.theaterstate import TheaterState -from game.data.doctrine import Doctrine from game.theater.theatergroundobject import IadsGroundObject from gen.flights.flight import FlightType @@ -25,8 +24,8 @@ class PlanDead(PackagePlanningTask[IadsGroundObject]): def apply_effects(self, state: TheaterState) -> None: state.eliminate_air_defense(self.target) - def propose_flights(self, doctrine: Doctrine) -> None: - self.propose_flight(FlightType.DEAD, 2, doctrine.mission_ranges.offensive) + def propose_flights(self) -> None: + self.propose_flight(FlightType.DEAD, 2) # Only include SEAD against SAMs that still have emitters. No need to # suppress an EWR, and SEAD isn't useful against a SAM that no longer has a @@ -41,18 +40,7 @@ class PlanDead(PackagePlanningTask[IadsGroundObject]): # package is *only* threatened by the target though. Could be improved, but # needs a decent refactor to the escort planning to do so. if self.target.has_live_radar_sam: - self.propose_flight(FlightType.SEAD, 2, doctrine.mission_ranges.offensive) + self.propose_flight(FlightType.SEAD, 2) else: - self.propose_flight( - FlightType.SEAD_ESCORT, - 2, - doctrine.mission_ranges.offensive, - EscortType.Sead, - ) - - self.propose_flight( - FlightType.ESCORT, - 2, - doctrine.mission_ranges.offensive, - EscortType.AirToAir, - ) + self.propose_flight(FlightType.SEAD_ESCORT, 2, EscortType.Sead) + self.propose_flight(FlightType.ESCORT, 2, EscortType.AirToAir) diff --git a/game/commander/tasks/primitive/oca.py b/game/commander/tasks/primitive/oca.py index 4c995f75..be88df32 100644 --- a/game/commander/tasks/primitive/oca.py +++ b/game/commander/tasks/primitive/oca.py @@ -4,7 +4,6 @@ from dataclasses import dataclass from game.commander.tasks.packageplanningtask import PackagePlanningTask from game.commander.theaterstate import TheaterState -from game.data.doctrine import Doctrine from game.theater import ControlPoint from gen.flights.flight import FlightType @@ -23,10 +22,8 @@ class PlanOcaStrike(PackagePlanningTask[ControlPoint]): def apply_effects(self, state: TheaterState) -> None: state.oca_targets.remove(self.target) - def propose_flights(self, doctrine: Doctrine) -> None: - self.propose_flight(FlightType.OCA_RUNWAY, 2, doctrine.mission_ranges.offensive) + def propose_flights(self) -> None: + self.propose_flight(FlightType.OCA_RUNWAY, 2) if self.aircraft_cold_start: - self.propose_flight( - FlightType.OCA_AIRCRAFT, 2, doctrine.mission_ranges.offensive - ) - self.propose_common_escorts(doctrine) + self.propose_flight(FlightType.OCA_AIRCRAFT, 2) + self.propose_common_escorts() diff --git a/game/commander/tasks/primitive/refueling.py b/game/commander/tasks/primitive/refueling.py index 005cbc3a..5f17f3df 100644 --- a/game/commander/tasks/primitive/refueling.py +++ b/game/commander/tasks/primitive/refueling.py @@ -4,7 +4,6 @@ from dataclasses import dataclass from game.commander.tasks.packageplanningtask import PackagePlanningTask from game.commander.theaterstate import TheaterState -from game.data.doctrine import Doctrine from game.theater import MissionTarget from gen.flights.flight import FlightType @@ -19,5 +18,5 @@ class PlanRefueling(PackagePlanningTask[MissionTarget]): def apply_effects(self, state: TheaterState) -> None: state.refueling_targets.remove(self.target) - def propose_flights(self, doctrine: Doctrine) -> None: - self.propose_flight(FlightType.REFUELING, 1, doctrine.mission_ranges.refueling) + def propose_flights(self) -> None: + self.propose_flight(FlightType.REFUELING, 1) diff --git a/game/commander/tasks/primitive/strike.py b/game/commander/tasks/primitive/strike.py index ce322dad..e89c9cac 100644 --- a/game/commander/tasks/primitive/strike.py +++ b/game/commander/tasks/primitive/strike.py @@ -5,7 +5,6 @@ from typing import Any from game.commander.tasks.packageplanningtask import PackagePlanningTask from game.commander.theaterstate import TheaterState -from game.data.doctrine import Doctrine from game.theater.theatergroundobject import TheaterGroundObject from gen.flights.flight import FlightType @@ -22,6 +21,6 @@ class PlanStrike(PackagePlanningTask[TheaterGroundObject[Any]]): def apply_effects(self, state: TheaterState) -> None: state.strike_targets.remove(self.target) - def propose_flights(self, doctrine: Doctrine) -> None: - self.propose_flight(FlightType.STRIKE, 2, doctrine.mission_ranges.offensive) - self.propose_common_escorts(doctrine) + def propose_flights(self) -> None: + self.propose_flight(FlightType.STRIKE, 2) + self.propose_common_escorts() diff --git a/game/data/doctrine.py b/game/data/doctrine.py index 21402501..359a1435 100644 --- a/game/data/doctrine.py +++ b/game/data/doctrine.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass, field +from dataclasses import dataclass from datetime import timedelta from typing import Any @@ -18,15 +18,6 @@ class GroundUnitProcurementRatios: return 0.0 -@dataclass(frozen=True) -class MissionPlannerMaxRanges: - cap: Distance = field(default=nautical_miles(100)) - cas: Distance = field(default=nautical_miles(50)) - offensive: Distance = field(default=nautical_miles(150)) - aewc: Distance = field(default=Distance.inf()) - refueling: Distance = field(default=nautical_miles(200)) - - @dataclass(frozen=True) class Doctrine: cas: bool @@ -88,8 +79,6 @@ class Doctrine: ground_unit_procurement_ratios: GroundUnitProcurementRatios - mission_ranges: MissionPlannerMaxRanges = field(default=MissionPlannerMaxRanges()) - @has_save_compat_for(5) def __setstate__(self, state: dict[str, Any]) -> None: if "max_ingress_distance" not in state: @@ -111,6 +100,12 @@ class Doctrine: self.__dict__.update(state) +class MissionPlannerMaxRanges: + @has_save_compat_for(5) + def __init__(self) -> None: + pass + + MODERN_DOCTRINE = Doctrine( cap=True, cas=True, diff --git a/game/dcs/aircrafttype.py b/game/dcs/aircrafttype.py index dd9b5282..56fa3f0f 100644 --- a/game/dcs/aircrafttype.py +++ b/game/dcs/aircrafttype.py @@ -29,7 +29,7 @@ from game.radio.channels import ( ViggenRadioChannelAllocator, NoOpChannelAllocator, ) -from game.utils import Distance, Speed, feet, kph, knots +from game.utils import Distance, Speed, feet, kph, knots, nautical_miles if TYPE_CHECKING: from gen.aircraft import FlightData @@ -112,13 +112,18 @@ class AircraftType(UnitType[Type[FlyingType]]): lha_capable: bool always_keeps_gun: bool - # If true, the aircraft does not use the guns as the last resort weapons, but as a main weapon. - # It'll RTB when it doesn't have gun ammo left. + # If true, the aircraft does not use the guns as the last resort weapons, but as a + # main weapon. It'll RTB when it doesn't have gun ammo left. gunfighter: bool max_group_size: int patrol_altitude: Optional[Distance] patrol_speed: Optional[Speed] + + #: 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 + intra_flight_radio: Optional[Radio] channel_allocator: Optional[RadioChannelAllocator] channel_namer: Type[ChannelNamer] @@ -230,6 +235,13 @@ class AircraftType(UnitType[Type[FlyingType]]): radio_config = RadioConfig.from_data(data.get("radios", {})) patrol_config = PatrolConfig.from_data(data.get("patrol", {})) + try: + mission_range = nautical_miles(int(data["max_range"])) + except (KeyError, ValueError): + mission_range = ( + nautical_miles(50) if aircraft.helicopter else nautical_miles(150) + ) + try: introduction = data["introduced"] if introduction is None: @@ -257,6 +269,7 @@ 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, + max_mission_range=mission_range, intra_flight_radio=radio_config.intra_flight, channel_allocator=radio_config.channel_allocator, channel_namer=radio_config.channel_namer, diff --git a/game/procurement.py b/game/procurement.py index 8820453c..3b1ea370 100644 --- a/game/procurement.py +++ b/game/procurement.py @@ -11,7 +11,7 @@ from game.dcs.aircrafttype import AircraftType from game.dcs.groundunittype import GroundUnitType from game.factions.faction import Faction from game.theater import ControlPoint, MissionTarget -from game.utils import Distance +from game.utils import meters from gen.flights.ai_flight_planner_db import aircraft_for_task from gen.flights.closestairfields import ObjectiveDistanceCache from gen.flights.flight import FlightType @@ -25,15 +25,13 @@ FRONTLINE_RESERVES_FACTOR = 1.3 @dataclass(frozen=True) class AircraftProcurementRequest: near: MissionTarget - range: Distance task_capability: FlightType number: int def __str__(self) -> str: task = self.task_capability.value - distance = self.range.nautical_miles target = self.near.name - return f"{self.number} ship {task} within {distance} nm of {target}" + return f"{self.number} ship {task} near {target}" class ProcurementAi: @@ -211,24 +209,24 @@ class ProcurementAi: return GroundUnitClass.Tank return worst_balanced - def _affordable_aircraft_for_task( - self, - task: FlightType, - airbase: ControlPoint, - number: int, - max_price: float, + def affordable_aircraft_for( + self, request: AircraftProcurementRequest, airbase: ControlPoint, budget: float ) -> Optional[AircraftType]: best_choice: Optional[AircraftType] = None - for unit in aircraft_for_task(task): + for unit in aircraft_for_task(request.task_capability): if unit not in self.faction.aircrafts: continue - if unit.price * number > max_price: + if unit.price * request.number > budget: continue if not airbase.can_operate(unit): continue + distance_to_target = meters(request.near.distance_to(airbase)) + if distance_to_target > unit.max_mission_range: + continue + for squadron in self.air_wing.squadrons_for(unit): - if task in squadron.auto_assignable_mission_types: + if request.task_capability in squadron.auto_assignable_mission_types: break else: continue @@ -241,13 +239,6 @@ class ProcurementAi: break return best_choice - def affordable_aircraft_for( - self, request: AircraftProcurementRequest, airbase: ControlPoint, budget: float - ) -> Optional[AircraftType]: - return self._affordable_aircraft_for_task( - request.task_capability, airbase, request.number, budget - ) - def fulfill_aircraft_request( self, request: AircraftProcurementRequest, budget: float ) -> Tuple[float, bool]: @@ -293,7 +284,7 @@ class ProcurementAi: ) -> Iterator[ControlPoint]: distance_cache = ObjectiveDistanceCache.get_closest_airfields(request.near) threatened = [] - for cp in distance_cache.operational_airfields_within(request.range): + for cp in distance_cache.operational_airfields: if not cp.is_friendly(self.is_player): continue if cp.unclaimed_parking(self.game) < request.number: diff --git a/game/transfers.py b/game/transfers.py index 7401b03d..68e8dea1 100644 --- a/game/transfers.py +++ b/game/transfers.py @@ -688,7 +688,5 @@ class PendingTransfers: gap += 1 self.game.procurement_requests_for(self.player).append( - AircraftProcurementRequest( - control_point, nautical_miles(200), FlightType.TRANSPORT, gap - ) + AircraftProcurementRequest(control_point, FlightType.TRANSPORT, gap) ) diff --git a/resources/units/aircraft/A-50.yaml b/resources/units/aircraft/A-50.yaml index 574f8cd9..bef84e03 100644 --- a/resources/units/aircraft/A-50.yaml +++ b/resources/units/aircraft/A-50.yaml @@ -1,5 +1,6 @@ description: The A-50 is an AWACS plane. max_group_size: 1 +max_range: 2000 price: 50 patrol: altitude: 33000 diff --git a/resources/units/aircraft/AV8BNA.yaml b/resources/units/aircraft/AV8BNA.yaml index 7e4fec82..3accfba7 100644 --- a/resources/units/aircraft/AV8BNA.yaml +++ b/resources/units/aircraft/AV8BNA.yaml @@ -27,6 +27,7 @@ manufacturer: McDonnell Douglas origin: USA/UK price: 15 role: V/STOL Attack +max_range: 100 variants: AV-8B Harrier II Night Attack: {} radios: diff --git a/resources/units/aircraft/An-26B.yaml b/resources/units/aircraft/An-26B.yaml index 4ed84aa7..01f90c19 100644 --- a/resources/units/aircraft/An-26B.yaml +++ b/resources/units/aircraft/An-26B.yaml @@ -1,4 +1,5 @@ description: The An-26B is a military transport aircraft. price: 15 +max_range: 800 variants: An-26B: null diff --git a/resources/units/aircraft/B-1B.yaml b/resources/units/aircraft/B-1B.yaml index 0d34fb01..3c309c7b 100644 --- a/resources/units/aircraft/B-1B.yaml +++ b/resources/units/aircraft/B-1B.yaml @@ -1,4 +1,5 @@ -description: The Rockwell B-1 Lancer is a supersonic variable-sweep wing, heavy bomber +description: + The Rockwell B-1 Lancer is a supersonic variable-sweep wing, heavy bomber used by the United States Air Force. It is commonly called the 'Bone' (from 'B-One').It is one of three strategic bombers in the U.S. Air Force fleet as of 2021, the other two being the B-2 Spirit and the B-52 Stratofortress. It first served in combat @@ -12,5 +13,6 @@ manufacturer: Rockwell origin: USA price: 45 role: Supersonic Strategic Bomber +max_range: 2000 variants: B-1B Lancer: {} diff --git a/resources/units/aircraft/B-52H.yaml b/resources/units/aircraft/B-52H.yaml index 65aa01c0..7221fd5e 100644 --- a/resources/units/aircraft/B-52H.yaml +++ b/resources/units/aircraft/B-52H.yaml @@ -1,4 +1,5 @@ -description: The Boeing B-52 Stratofortress is capable of carrying up to 70,000 pounds +description: + The Boeing B-52 Stratofortress is capable of carrying up to 70,000 pounds (32,000 kg) of weapons, and has a typical combat range of more than 8,800 miles (14,080 km) without aerial refueling. The B-52 completed sixty years of continuous service with its original operator in 2015. After being upgraded between 2013 and @@ -8,5 +9,6 @@ manufacturer: Boeing origin: USA price: 35 role: Strategic Bomber +max_range: 2000 variants: B-52H Stratofortress: {} diff --git a/resources/units/aircraft/C-130.yaml b/resources/units/aircraft/C-130.yaml index 4efe0d0a..eca68ffa 100644 --- a/resources/units/aircraft/C-130.yaml +++ b/resources/units/aircraft/C-130.yaml @@ -1,4 +1,5 @@ description: The C-130 is a military transport aircraft. price: 15 +max_range: 1000 variants: C-130: null diff --git a/resources/units/aircraft/C-17A.yaml b/resources/units/aircraft/C-17A.yaml index a121e07b..692e24a9 100644 --- a/resources/units/aircraft/C-17A.yaml +++ b/resources/units/aircraft/C-17A.yaml @@ -1,4 +1,5 @@ description: The C-17 is a military transport aircraft. price: 18 +max_range: 2000 variants: C-17A: null diff --git a/resources/units/aircraft/E-2C.yaml b/resources/units/aircraft/E-2C.yaml index ca25d97e..7154813a 100644 --- a/resources/units/aircraft/E-2C.yaml +++ b/resources/units/aircraft/E-2C.yaml @@ -8,6 +8,7 @@ manufacturer: Northrop Grumman origin: USA price: 50 role: AEW&C +max_range: 2000 patrol: altitude: 30000 variants: diff --git a/resources/units/aircraft/E-3A.yaml b/resources/units/aircraft/E-3A.yaml index ca781a23..a8e676e4 100644 --- a/resources/units/aircraft/E-3A.yaml +++ b/resources/units/aircraft/E-3A.yaml @@ -1,6 +1,7 @@ description: The E-3A is a AWACS aicraft. price: 50 max_group_size: 1 +max_range: 2000 patrol: altitude: 35000 variants: diff --git a/resources/units/aircraft/F-14A-135-GR.yaml b/resources/units/aircraft/F-14A-135-GR.yaml index eb593105..b467d7e9 100644 --- a/resources/units/aircraft/F-14A-135-GR.yaml +++ b/resources/units/aircraft/F-14A-135-GR.yaml @@ -21,6 +21,7 @@ manufacturer: Grumman origin: USA price: 22 role: Carrier-based Air-Superiority Fighter/Fighter Bomber +max_range: 250 variants: F-14A Tomcat (Block 135-GR Late): {} radios: diff --git a/resources/units/aircraft/F-14B.yaml b/resources/units/aircraft/F-14B.yaml index a6244a4a..2cc64fa4 100644 --- a/resources/units/aircraft/F-14B.yaml +++ b/resources/units/aircraft/F-14B.yaml @@ -21,6 +21,7 @@ manufacturer: Grumman origin: USA price: 26 role: Carrier-based Air-Superiority Fighter/Fighter Bomber +max_range: 250 variants: F-14B Tomcat: {} radios: diff --git a/resources/units/aircraft/F-16A.yaml b/resources/units/aircraft/F-16A.yaml index 99c2c2e5..fdfcdb5c 100644 --- a/resources/units/aircraft/F-16A.yaml +++ b/resources/units/aircraft/F-16A.yaml @@ -1,4 +1,5 @@ description: The early verison of the F-16. It flew in Desert Storm. price: 15 +max_range: 200 variants: F-16A: null diff --git a/resources/units/aircraft/F-16C_50.yaml b/resources/units/aircraft/F-16C_50.yaml index 9e5b3740..adefe831 100644 --- a/resources/units/aircraft/F-16C_50.yaml +++ b/resources/units/aircraft/F-16C_50.yaml @@ -27,6 +27,7 @@ manufacturer: General Dynamics origin: USA price: 22 role: Multirole Fighter +max_range: 200 variants: F-16CM Fighting Falcon (Block 50): {} F-2A: {} diff --git a/resources/units/aircraft/Hercules.yaml b/resources/units/aircraft/Hercules.yaml index 070409fa..af82aaa6 100644 --- a/resources/units/aircraft/Hercules.yaml +++ b/resources/units/aircraft/Hercules.yaml @@ -1,4 +1,5 @@ -description: The Lockheed Martin C-130J Super Hercules is a four-engine turboprop +description: + The Lockheed Martin C-130J Super Hercules is a four-engine turboprop military transport aircraft. The C-130J is a comprehensive update of the Lockheed C-130 Hercules, with new engines, flight deck, and other systems. As of February 2018, 400 C-130J aircraft have been delivered to 17 nations. @@ -7,5 +8,6 @@ manufacturer: Lockheed origin: USA price: 18 role: Transport +max_range: 1000 variants: C-130J-30 Super Hercules: {} diff --git a/resources/units/aircraft/IL-76MD.yaml b/resources/units/aircraft/IL-76MD.yaml index 97020aca..74ca1ab1 100644 --- a/resources/units/aircraft/IL-76MD.yaml +++ b/resources/units/aircraft/IL-76MD.yaml @@ -1,3 +1,4 @@ price: 20 +max_range: 1000 variants: IL-76MD: null diff --git a/resources/units/aircraft/IL-78M.yaml b/resources/units/aircraft/IL-78M.yaml index 2acd1ea3..de5b76f2 100644 --- a/resources/units/aircraft/IL-78M.yaml +++ b/resources/units/aircraft/IL-78M.yaml @@ -1,5 +1,6 @@ price: 20 max_group_size: 1 +max_range: 1000 patrol: # ~280 knots IAS. speed: 400 diff --git a/resources/units/aircraft/KC-135.yaml b/resources/units/aircraft/KC-135.yaml index 138ae873..2cb5e40d 100644 --- a/resources/units/aircraft/KC-135.yaml +++ b/resources/units/aircraft/KC-135.yaml @@ -8,6 +8,7 @@ manufacturer: Beoing origin: USA price: 25 role: Tanker +max_range: 1000 patrol: # ~300 knots IAS. speed: 445 diff --git a/resources/units/aircraft/KC130.yaml b/resources/units/aircraft/KC130.yaml index 802a16bb..5b9ffdca 100644 --- a/resources/units/aircraft/KC130.yaml +++ b/resources/units/aircraft/KC130.yaml @@ -1,10 +1,12 @@ -description: The Lockheed Martin (previously Lockheed) KC-130 is a family of the extended-range +description: + The Lockheed Martin (previously Lockheed) KC-130 is a family of the extended-range tanker version of the C-130 Hercules transport aircraft modified for aerial refueling. introduced: 1962 manufacturer: Lockheed Martin origin: USA price: 25 role: Tanker +max_range: 1000 patrol: # ~210 knots IAS, roughly the max for the KC-130 at altitude. speed: 370 diff --git a/resources/units/aircraft/KC135MPRS.yaml b/resources/units/aircraft/KC135MPRS.yaml index 4ba28ff5..c59d3098 100644 --- a/resources/units/aircraft/KC135MPRS.yaml +++ b/resources/units/aircraft/KC135MPRS.yaml @@ -1,4 +1,5 @@ -description: The Boeing KC-135 Stratotanker is a military aerial refueling aircraft +description: + The Boeing KC-135 Stratotanker is a military aerial refueling aircraft that was developed from the Boeing 367-80 prototype, alongside the Boeing 707 airliner. This model has the Multi-point Refueling System modification, allowing for probe and drogue refuelling. @@ -7,6 +8,7 @@ manufacturer: Boeing origin: USA price: 25 role: Tanker +max_range: 1000 patrol: # 300 knots IAS. speed: 440 diff --git a/resources/units/aircraft/KJ-2000.yaml b/resources/units/aircraft/KJ-2000.yaml index cbb843c8..8078c359 100644 --- a/resources/units/aircraft/KJ-2000.yaml +++ b/resources/units/aircraft/KJ-2000.yaml @@ -1,4 +1,5 @@ price: 50 +max_range: 2000 patrol: altitude: 40000 variants: diff --git a/resources/units/aircraft/S-3B Tanker.yaml b/resources/units/aircraft/S-3B Tanker.yaml index dabe7056..6a4680e3 100644 --- a/resources/units/aircraft/S-3B Tanker.yaml +++ b/resources/units/aircraft/S-3B Tanker.yaml @@ -1,5 +1,6 @@ carrier_capable: true -description: The Lockheed S-3 Viking is a 4-crew, twin-engine turbofan-powered jet +description: + The Lockheed S-3 Viking is a 4-crew, twin-engine turbofan-powered jet aircraft that was used by the U.S. Navy (USN) primarily for anti-submarine warfare. In the late 1990s, the S-3B's mission focus shifted to surface warfare and aerial refueling. The Viking also provided electronic warfare and surface surveillance @@ -16,6 +17,7 @@ origin: USA price: 20 max_group_size: 1 role: Carrier-based Tanker +max_range: 1000 patrol: # ~265 knots IAS. speed: 320 diff --git a/resources/units/aircraft/Yak-40.yaml b/resources/units/aircraft/Yak-40.yaml index d56a2b65..d69242fc 100644 --- a/resources/units/aircraft/Yak-40.yaml +++ b/resources/units/aircraft/Yak-40.yaml @@ -1,3 +1,4 @@ price: 25 +max_range: 600 variants: Yak-40: null From 0a57bb5029cb0e60cc3169b4c6da8296c657c8df Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Sat, 17 Jul 2021 15:30:43 -0700 Subject: [PATCH 21/42] Increase airfield distance in Battle of Abu Dhabi. Removes some of the low capacity airfields from the campaign now that missions can plan longer ranges if needed. This removes Khasab, Bandar Lengeh, and Qeshm from the blue side, so blue no longer has any airfields on the peninsula. The CVN has moved quite a ways west to make it a good platform for attacking the area around Dubai, and to prevent it from being the primary mission source (with a 90 aircraft limit, a *lot* of missions can get planned there before other airbases will be used). The LHA moves to near where the CVN was, making it a good platform for early game missions. Once the LHA's 20 aircraft limit is exhausted, Kish and Bandar Abbas will be the primary airfields early game. Bandar Abbas is still close enough to source Hornet and Viper missions to most of the area around Dubai. It's unable to reach Lar with those aircraft, but Kish and the CVN can (as can captured airfields). --- resources/campaigns/battle_of_abu_dhabi.json | 2 +- resources/campaigns/battle_of_abu_dhabi.miz | Bin 46467 -> 42833 bytes 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/campaigns/battle_of_abu_dhabi.json b/resources/campaigns/battle_of_abu_dhabi.json index 9cfa5476..8bbd80fb 100644 --- a/resources/campaigns/battle_of_abu_dhabi.json +++ b/resources/campaigns/battle_of_abu_dhabi.json @@ -4,7 +4,7 @@ "authors": "Colonel Panic", "recommended_player_faction": "Iran 2015", "recommended_enemy_faction": "United Arab Emirates 2015", - "description": "

You have managed to establish a foothold at Khasab. Continue pushing south.

", + "description": "

You have managed to establish a foothold near Ras Al Khaima. Continue pushing south.

", "miz": "battle_of_abu_dhabi.miz", "performance": 2, "version": "7.0" diff --git a/resources/campaigns/battle_of_abu_dhabi.miz b/resources/campaigns/battle_of_abu_dhabi.miz index a3c11d5e240898976648c436d938a7cfac8832d1..dcc30acffbf65f5e9f658c56fb700d5f553dce36 100644 GIT binary patch delta 38694 zcmZVF1yoeq{{VVKxXNrwDj(@F*y7aBwf-wtQ4fXLaD<0%FkNkYVpw znK?L^*;wy3>o`pJMwfc->n|QSHD*=FYdIb2_15g1Rbfv>H@HJVteM}88cIyHjy4uJ zK+msR0XRAc9x9q>)(7~L#Aj0RC=87~dFq?^cm&;V?)kk=fYu0d!u!kh zm?CgYYx<4GWz5li`kmtyOZIZn(iaxsX8BhC0%+y)x;i<$*jZjaR;X;{ckn-~J)9gX zB&@q#O==ZvX=&oUuUML&zdQOh4>Sn82OXB>wenuq=4H34-%dly2E(s!c9x}Lx>9a^ z{VyqW!|A)LRe z9v19ozh_R>zp-#UJj{>q1(8mV5+2<2?D3r|IPA|1q+A%C2(rkBmw^-l7lHcA!|^4P zal+*U+Pvj2*(}RnI2PNN%H~@()@#9)HxV{o>zr9c`s9Fqss|Gpv@xZNpH%OTzrH)h zKEE}rm4fLq_x1UTGyFYqM5(}#_93X)<8<}r$l`ElIlE|IzX$w&p;dm+JoS2m-n>M^ zU6Q#qylBAV-Es_Ajqm3GCSmuXxxsjK_{v#4`e)W{&~pbsZtc#~yjv^Sf%D3iSK!L= zPUBRZ%@dHoDq|7AQ`(!f>xwztT{&9mgls~_&I2?}6whP8T->4)}x*{3N4xg|{Z4+H>Rl>qy7)=bJKkRi8hhjs4dadUfda%0(4 zq26$Pnq-lRFJN#beD7-S%+YeOYFAzxF-Wd|2i)F5cA5nUw(?!ZhnQ^=uID7JOX@tF zLJ}AEm*(Q;T-#&&6RhpClSlxbdzYh4!<)l{6=P!bp^*Ux_3+j!!Vt0gJC{kr3vm|N zkC46@mxEU;#0vX&hBim7DYVC}$DbTsk7q8riZbvtxbNJYoV6->YgG#DcfVkdGqqgk z0yhh5TAmeLEp>b^(wZ9tZXMTaWP-0W4v)=SZ}*X~=UPqD8_>x3njy0^7u8n*JF+ju`sc4GtS00ntM#^YS4*$@8VLHC^KWFQyB;qjpylRb z(=gn5j?I{@=f}Y^gmkwfpVp!3Sf%zi4>OfW_pMXTY*Oz9zr0Ud>4?aS zN!7O^S>qbojG(QoI<_#Ntvo>|Od7QJnz3e+N!O?6N}?Xp|0&1BWAlzHDObPl%rM@| z>nrSUf`YDG)vF!Vw3Ruo+4T@cz_tCUtO<{xwO~?iMs0Y;uh9XP;*6`*%gu|;r|$)E zVQ=dk@V1!GAIC^F_*=-*Y_6Fs)5bWWcc#m-4YGVpOtxR_`7OnK zu#8?|9&(u>pSIn>qL8+I^UZZjcOBsMRENFH3a(|Gb9<_;uao7er*g@r$__K>8nSh^ z$z*3ykk~)}!8_X--=iHyxpLxjBJ=qp7l|z+{c-2vy)Kj5%BmpwNDTFxPpvPcC(0Ub zD-B+d`@z??+8XW$upXBI*J2xY!}{|#*HK!WfhGfM2bQd%7SFo)>8i{!X<8yQo=?pu zfOqb-gbeQMCTHFZiqD6)q-QXCuohGIRswal13G#xwYaT}A5OO`i@^I~!B+$NtDbrQ znu@HtkZAygfazinxRo1NUca}yI6AECY8-kQJw&UYp$hW6B=Z8krX$~-ED8c2)r<5$ z1_-69EBmbij%wU_{j}BR`c@dnQ!Z}1_~Le+&U{9YyzK6GZ`AM2Q!S9VnB5@g!+Q-3 z$=c3~B<60%)H?U02iW_=r!k$|;L4cFR`UHgi-Vj^Cui(0hELy!-}z<;y4F_PuX#uln3lM75vMOh{TAW?Xf9sCmBEP@s4?LDKJp(V$>q`)3 zvm^{iFp@ZZ)=1s2fzONG@2>o+bM;k&uXshFE4=(wxA0b5zvR|sPv-~wY^{Nc3nw{QE$64Z)uH#h^?I|n(T9Z@eF!aw zlZTEeRAjUnRMms75z9)M3i5Xiv+iEgK=kQWhJ=a9@^8}p`CD|@2*≪BZKLpX4+{ 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%0D`+YH#Bkrxx8dN@IJh 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 z Date: Sat, 17 Jul 2021 17:27:40 -0700 Subject: [PATCH 22/42] Log a warning for unknown max ranges. --- game/dcs/aircrafttype.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/game/dcs/aircrafttype.py b/game/dcs/aircrafttype.py index 56fa3f0f..c51ef65d 100644 --- a/game/dcs/aircrafttype.py +++ b/game/dcs/aircrafttype.py @@ -241,6 +241,10 @@ class AircraftType(UnitType[Type[FlyingType]]): mission_range = ( nautical_miles(50) if aircraft.helicopter else nautical_miles(150) ) + logging.warning( + f"{aircraft.id} does not specify a max_range. Defaulting to " + f"{mission_range.nautical_miles}NM" + ) try: introduction = data["introduced"] From 3c90a9264136640b25a2750cabb4789357e4382a Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Sat, 17 Jul 2021 18:23:20 -0700 Subject: [PATCH 23/42] Add fuel consumption data for the Hornet. Will be used to calculate bingo and min remaining fuel for the kneeboard. --- doc/fuel-consumption-measurement.md | 80 +++++++++++++++++++++ resources/units/aircraft/FA-18C_hornet.yaml | 10 +++ 2 files changed, 90 insertions(+) create mode 100644 doc/fuel-consumption-measurement.md diff --git a/doc/fuel-consumption-measurement.md b/doc/fuel-consumption-measurement.md new file mode 100644 index 00000000..62dd0a1b --- /dev/null +++ b/doc/fuel-consumption-measurement.md @@ -0,0 +1,80 @@ +# Measuring estimated fuel consumption + +To estimate fuel consumption numbers for an aircraft, create a mission with a +typical heavy load for the aircraft. For example, to measure for the F/A-18C, a +loadout with two bags, two GBU-31s, two sidewinders, an AMRAAM, and an ATFLIR. +Do **not** drop bags or weapons during the test flight. + +Start the aircraft on the ground at a large airport (for example, Akrotiri) at a +parking space at the opposite end of the takeoff runway so you can estimate long +taxi fuel consumption. + +When you enter the jet, note the amount of fuel below, then taxi to the far end +of the runway. Hold short and note the remaining fuel below. + +Follow a typical takeoff pattern for the aircraft. For the F/A-18C, this might +be AB takeoff, reduce to MIL at 350KIAS, and maintian 350KIAS/0.85 mach until +cruise altitude (angles 25). + +Once you reach angels 25, pause the game. Note your remaining fuel below and +measure the distance traveled from takeoff. Mark your location on the map. + +Level out and increase to cruise speed if needed. Liberation assumes 0.85 mach +for supersonic aircraft, for subsonic aircraft it depends so pick something +reasonable and note your descision in a comment in the file when done. Maintain +speed, heading, and altitude for a long distance (the longer the distance, the +more accurate the result, but be careful to leave enough fuel for the final +section). Once complete, note the distance traveled and the remaining fuel. + +Finally, increase speed as you would for an attack. At least MIL power, +potentially use AB sparingly, etc. The goal is to measure fuel consumption per +mile traveled during an attack run. + +``` +start: +taxi end: +to 25k distance: +at 25k fuel: +cruise (.85 mach) distance: +cruise (.85 mach) end fuel: +combat distance: +combat end fuel: +``` + +Finally, fill out the data in the aircraft data. Below is an example for the +F/A-18C: + +``` +start: 15290 +taxi end: 15120 +climb distance: 40NM +at 25k fuel: 13350 +cruise (.85 mach) distance: 100NM +cruise (.85 mach) end fuel: 11140 +combat distance: 100NM +combat end fuel: 8390 + +taxi = start - taxi end = 15290 - 15120 = 170 +climb fuel = taxi end - at 25k fuel = 15120 - 13350 = 1770 +climb ppm = climb fuel / climb distance = 1770 / 40 = 44.25 +cruise fuel = at 25k fuel - cruise end fuel = 13350 - 11140 = 2210 +cruise ppm = cruise fuel / cruise distance = 2210 / 100 = 22.1 +combat fuel = cruise end fuel - combat end fuel = 11140 - 8390 = 2750 +combat ppm = combat fuel / combat distance = 2750 / 100 = 27.5 +``` + +```yaml +fuel: + # Parking A1 to RWY 32 at Akrotiri. + taxi: 170 + # AB takeoff to 350/0.85, reduce to MIL and maintain 350 to 25k ft. + climb_ppm: 44.25 + # 0.85 mach for 100NM. + cruise_ppm: 22.1 + # ~0.9 mach for 100NM. Occasional AB use. + combat_ppm: 27.5 + min_safe: 2000 +``` + +The last entry (`min_safe`) is the minimum amount of fuel that the aircraft +should land with. diff --git a/resources/units/aircraft/FA-18C_hornet.yaml b/resources/units/aircraft/FA-18C_hornet.yaml index 06f616d2..511a0b48 100644 --- a/resources/units/aircraft/FA-18C_hornet.yaml +++ b/resources/units/aircraft/FA-18C_hornet.yaml @@ -21,6 +21,16 @@ manufacturer: McDonnell Douglas origin: USA price: 24 role: Carrier-based Multirole Fighter +fuel: + # Parking A1 to RWY 32 at Akrotiri. + taxi: 170 + # AB takeoff to 350/0.85, reduce to MIL and maintain 350 to 25k ft. + climb_ppm: 44.25 + # 0.85 mach for 100NM. + cruise_ppm: 22.1 + # ~0.9 mach for 100NM. Occasional AB use. + combat_ppm: 27.5 + min_safe: 2000 variants: CF-188 Hornet: {} EF-18A+ Hornet: {} From c11c6f40d5010bf4c9418d68780977e4820abb0d Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Sat, 17 Jul 2021 19:51:55 -0700 Subject: [PATCH 24/42] Add minimum fuel per waypoint on the kneeboard. --- game/dcs/aircrafttype.py | 40 ++++++++++++++++++++++++++++ game/utils.py | 7 +++-- gen/aircraft.py | 56 ++++++++++++++++++++++++++++++++++++--- gen/flights/flight.py | 13 +++++++-- gen/flights/flightplan.py | 12 +++++++++ gen/kneeboard.py | 37 +++++++++++++++++++++++--- 6 files changed, 154 insertions(+), 11 deletions(-) diff --git a/game/dcs/aircrafttype.py b/game/dcs/aircrafttype.py index c51ef65d..5158f240 100644 --- a/game/dcs/aircrafttype.py +++ b/game/dcs/aircrafttype.py @@ -105,6 +105,35 @@ class PatrolConfig: ) +@dataclass(frozen=True) +class FuelConsumption: + #: The estimated taxi fuel requirement, in pounds. + taxi: int + + #: The estimated fuel consumption for a takeoff climb, in pounds per nautical mile. + climb: float + + #: The estimated fuel consumption for cruising, in pounds per nautical mile. + cruise: float + + #: The estimated fuel consumption for combat speeds, in pounds per nautical mile. + combat: float + + #: The minimum amount of fuel that the aircraft should land with, in pounds. This is + #: a reserve amount for landing delays or emergencies. + min_safe: int + + @classmethod + def from_data(cls, data: dict[str, Any]) -> FuelConsumption: + return FuelConsumption( + int(data["taxi"]), + float(data["climb_ppm"]), + float(data["cruise_ppm"]), + float(data["combat_ppm"]), + int(data["min_safe"]), + ) + + # TODO: Split into PlaneType and HelicopterType? @dataclass(frozen=True) class AircraftType(UnitType[Type[FlyingType]]): @@ -124,6 +153,8 @@ class AircraftType(UnitType[Type[FlyingType]]): #: planner will consider this aircraft usable for a mission. max_mission_range: Distance + fuel_consumption: Optional[FuelConsumption] + intra_flight_radio: Optional[Radio] channel_allocator: Optional[RadioChannelAllocator] channel_namer: Type[ChannelNamer] @@ -246,6 +277,14 @@ class AircraftType(UnitType[Type[FlyingType]]): f"{mission_range.nautical_miles}NM" ) + fuel_data = data.get("fuel") + if fuel_data is not None: + fuel_consumption: Optional[FuelConsumption] = FuelConsumption.from_data( + fuel_data + ) + else: + fuel_consumption = None + try: introduction = data["introduced"] if introduction is None: @@ -274,6 +313,7 @@ class AircraftType(UnitType[Type[FlyingType]]): patrol_altitude=patrol_config.altitude, patrol_speed=patrol_config.speed, max_mission_range=mission_range, + fuel_consumption=fuel_consumption, intra_flight_radio=radio_config.intra_flight, channel_allocator=radio_config.channel_allocator, channel_namer=radio_config.channel_namer, diff --git a/game/utils.py b/game/utils.py index 39daa058..b21b11df 100644 --- a/game/utils.py +++ b/game/utils.py @@ -4,7 +4,7 @@ import itertools import math from collections import Iterable from dataclasses import dataclass -from typing import Union, Any +from typing import Union, Any, TypeVar METERS_TO_FEET = 3.28084 FEET_TO_METERS = 1 / METERS_TO_FEET @@ -205,7 +205,10 @@ def inches_hg(value: float) -> Pressure: return Pressure(value) -def pairwise(iterable: Iterable[Any]) -> Iterable[tuple[Any, Any]]: +PairwiseT = TypeVar("PairwiseT") + + +def pairwise(iterable: Iterable[PairwiseT]) -> Iterable[tuple[PairwiseT, PairwiseT]]: """ itertools recipe s -> (s0,s1), (s1,s2), (s2, s3), ... diff --git a/gen/aircraft.py b/gen/aircraft.py index 392cd70c..144344df 100644 --- a/gen/aircraft.py +++ b/gen/aircraft.py @@ -1,5 +1,6 @@ from __future__ import annotations +import itertools import logging import random from dataclasses import dataclass @@ -80,7 +81,7 @@ from game.theater.missiontarget import MissionTarget from game.theater.theatergroundobject import TheaterGroundObject from game.transfers import MultiGroupTransport from game.unitmap import UnitMap -from game.utils import Distance, meters, nautical_miles +from game.utils import Distance, meters, nautical_miles, pairwise from gen.ato import AirTaskingOrder, Package from gen.callsigns import create_group_callsign_from_unit from gen.flights.flight import ( @@ -1194,8 +1195,57 @@ class AircraftConflictGenerator: ).build() # Set here rather than when the FlightData is created so they waypoints - # have their TOTs set. - self.flights[-1].waypoints = [takeoff_point] + flight.points + # have their TOTs and fuel minimums set. Once we're more confident in our fuel + # estimation ability the minimum fuel amounts will be calculated during flight + # plan construction, but for now it's only used by the kneeboard so is generated + # late. + waypoints = [takeoff_point] + flight.points + self._estimate_min_fuel_for(flight, waypoints) + self.flights[-1].waypoints = waypoints + + @staticmethod + def _estimate_min_fuel_for(flight: Flight, waypoints: list[FlightWaypoint]) -> None: + if flight.unit_type.fuel_consumption is None: + return + + combat_speed_types = { + FlightWaypointType.INGRESS_BAI, + FlightWaypointType.INGRESS_CAS, + FlightWaypointType.INGRESS_DEAD, + FlightWaypointType.INGRESS_ESCORT, + FlightWaypointType.INGRESS_OCA_AIRCRAFT, + FlightWaypointType.INGRESS_OCA_RUNWAY, + FlightWaypointType.INGRESS_SEAD, + FlightWaypointType.INGRESS_STRIKE, + FlightWaypointType.INGRESS_SWEEP, + FlightWaypointType.SPLIT, + } | set(TARGET_WAYPOINTS) + + consumption = flight.unit_type.fuel_consumption + min_fuel: float = consumption.min_safe + + # The flight plan (in reverse) up to and including the arrival point. + main_flight_plan = reversed(waypoints) + try: + while waypoint := next(main_flight_plan): + if waypoint.waypoint_type is FlightWaypointType.LANDING_POINT: + waypoint.min_fuel = min_fuel + main_flight_plan = itertools.chain([waypoint], main_flight_plan) + break + except StopIteration: + # Some custom flight plan without a landing point. Skip it. + return + + for b, a in pairwise(main_flight_plan): + distance = meters(a.position.distance_to_point(b.position)) + if a.waypoint_type is FlightWaypointType.TAKEOFF: + ppm = consumption.climb + elif b.waypoint_type in combat_speed_types: + ppm = consumption.combat + else: + ppm = consumption.cruise + min_fuel += distance.nautical_miles * ppm + a.min_fuel = min_fuel def should_delay_flight(self, flight: Flight, start_time: timedelta) -> bool: if start_time.total_seconds() <= 0: diff --git a/gen/flights/flight.py b/gen/flights/flight.py index 05ee4c19..5c3241f7 100644 --- a/gen/flights/flight.py +++ b/gen/flights/flight.py @@ -2,13 +2,14 @@ from __future__ import annotations from datetime import timedelta from enum import Enum -from typing import List, Optional, TYPE_CHECKING, Union, Sequence +from typing import List, Optional, TYPE_CHECKING, Union, Sequence, Any from dcs.mapping import Point from dcs.point import MovingPoint, PointAction from dcs.unit import Unit from game.dcs.aircrafttype import AircraftType +from game.savecompat import has_save_compat_for from game.squadrons import Pilot, Squadron from game.theater.controlpoint import ControlPoint, MissionTarget from game.utils import Distance, meters @@ -138,7 +139,7 @@ class FlightWaypoint: Args: waypoint_type: The waypoint type. - x: X cooidinate of the waypoint. + x: X coordinate of the waypoint. y: Y coordinate of the waypoint. alt: Altitude of the waypoint. By default this is AGL, but it can be changed to MSL by setting alt_type to "RADIO". @@ -158,6 +159,8 @@ class FlightWaypoint: self.pretty_name = "" self.only_for_player = False self.flyover = False + # The minimum amount of fuel remaining at this waypoint in pounds. + self.min_fuel: Optional[float] = None # These are set very late by the air conflict generator (part of mission # generation). We do it late so that we don't need to propagate changes @@ -166,6 +169,12 @@ class FlightWaypoint: self.tot: Optional[timedelta] = None self.departure_time: Optional[timedelta] = None + @has_save_compat_for(5) + def __setstate__(self, state: dict[str, Any]) -> None: + if "min_fuel" not in state: + state["min_fuel"] = None + self.__dict__.update(state) + @property def position(self) -> Point: return Point(self.x, self.y) diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py index 4f28e4cf..6c25babd 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -20,6 +20,7 @@ from dcs.unit import Unit from shapely.geometry import Point as ShapelyPoint from game.data.doctrine import Doctrine +from game.dcs.aircrafttype import FuelConsumption from game.flightplan import IpZoneGeometry, JoinZoneGeometry, HoldZoneGeometry from game.theater import ( Airfield, @@ -138,6 +139,17 @@ class FlightPlan: @cached_property def bingo_fuel(self) -> int: """Bingo fuel value for the FlightPlan""" + if (fuel := self.flight.unit_type.fuel_consumption) is not None: + return self._bingo_estimate(fuel) + return self._legacy_bingo_estimate() + + def _bingo_estimate(self, fuel: FuelConsumption) -> int: + distance_to_arrival = self.max_distance_from(self.flight.arrival) + fuel_consumed = fuel.cruise * distance_to_arrival.nautical_miles + bingo = fuel_consumed + fuel.min_safe + return math.ceil(bingo / 100) * 100 + + def _legacy_bingo_estimate(self) -> int: distance_to_arrival = self.max_distance_from(self.flight.arrival) bingo = 1000.0 # Minimum Emergency Fuel diff --git a/gen/kneeboard.py b/gen/kneeboard.py index 20fb8ca1..1b0f09b1 100644 --- a/gen/kneeboard.py +++ b/gen/kneeboard.py @@ -23,6 +23,7 @@ only be added per airframe, so PvP missions where each side have the same aircraft will be able to see the enemy's kneeboard for the same airframe. """ import datetime +import math import textwrap from collections import defaultdict from dataclasses import dataclass @@ -39,8 +40,8 @@ from game.db import unit_type_from_name from game.dcs.aircrafttype import AircraftType from game.theater import ConflictTheater, TheaterGroundObject, LatLon from game.theater.bullseye import Bullseye -from game.weather import Weather from game.utils import meters +from game.weather import Weather from .aircraft import FlightData from .airsupportgen import AwacsInfo, TankerInfo from .briefinggen import CommInfo, JtacInfo, MissionInfoGenerator @@ -111,12 +112,17 @@ class KneeboardPageWriter: self.text(text, font=self.heading_font, fill=self.foreground_fill) def table( - self, cells: List[List[str]], headers: Optional[List[str]] = None + self, + cells: List[List[str]], + headers: Optional[List[str]] = None, + font: Optional[ImageFont.FreeTypeFont] = None, ) -> None: if headers is None: headers = [] + if font is None: + font = self.table_font table = tabulate(cells, headers=headers, numalign="right") - self.text(table, font=self.table_font, fill=self.foreground_fill) + self.text(table, font, fill=self.foreground_fill) def write(self, path: Path) -> None: self.image.save(path) @@ -199,6 +205,7 @@ class FlightPlanBuilder: self._ground_speed(self.target_points[0].waypoint), self._format_time(self.target_points[0].waypoint.tot), self._format_time(self.target_points[0].waypoint.departure_time), + self._format_min_fuel(self.target_points[0].waypoint.min_fuel), ] ) self.last_waypoint = self.target_points[-1].waypoint @@ -216,6 +223,7 @@ class FlightPlanBuilder: self._ground_speed(waypoint.waypoint), self._format_time(waypoint.waypoint.tot), self._format_time(waypoint.waypoint.departure_time), + self._format_min_fuel(waypoint.waypoint.min_fuel), ] ) @@ -254,6 +262,12 @@ class FlightPlanBuilder: duration = (waypoint.tot - last_time).total_seconds() / 3600 return f"{int(distance.nautical_miles / duration)} kt" + @staticmethod + def _format_min_fuel(min_fuel: Optional[float]) -> str: + if min_fuel is None: + return "" + return str(math.ceil(min_fuel / 100) * 100) + def build(self) -> List[List[str]]: return self.rows @@ -276,6 +290,11 @@ class BriefingPage(KneeboardPage): self.weather = weather self.start_time = start_time self.dark_kneeboard = dark_kneeboard + self.flight_plan_font = ImageFont.truetype( + "resources/fonts/Inconsolata.otf", + 16, + layout_engine=ImageFont.LAYOUT_BASIC, + ) def write(self, path: Path) -> None: writer = KneeboardPageWriter(dark_theme=self.dark_kneeboard) @@ -302,7 +321,17 @@ class BriefingPage(KneeboardPage): flight_plan_builder.add_waypoint(num, waypoint) writer.table( flight_plan_builder.build(), - headers=["#", "Action", "Alt", "Dist", "GSPD", "Time", "Departure"], + headers=[ + "#", + "Action", + "Alt", + "Dist", + "GSPD", + "Time", + "Departure", + "Min fuel", + ], + font=self.flight_plan_font, ) writer.text(f"Bullseye: {self.bullseye.to_lat_lon(self.theater).format_dms()}") From 2580fe6b79c89fa73018dfe754183f67ae8ebc30 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Sat, 17 Jul 2021 19:53:16 -0700 Subject: [PATCH 25/42] Update the changelog. --- changelog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/changelog.md b/changelog.md index e0e61174..0d4174d9 100644 --- a/changelog.md +++ b/changelog.md @@ -10,6 +10,7 @@ Saves from 3.x are not compatible with 5.0. * **[Campaign AI]** Player front line stances can now be automated. Improved stance selection for AI. * **[Campaign AI]** Reworked layout of hold, join, split, and ingress points. Should result in much shorter flight plans in general while still maintaining safe join/split/hold points. * **[Campaign AI]** Auto-planning mission range limits are now specified per-aircraft. On average this means that longer range missions will now be plannable. The limit only accounts for the direct distance to the target, not the path taken. +* **[Kneeboard]** Minimum required fuel estimates have been added to the kneeboard for aircraft with supporting data (currently only the Hornet). * **[New Game Wizard]** Can now customize the player's air wing before campaign start to disable or rename squadrons. ## Fixes From e22e8669e18194b4a3246120f50bd860f96d2d53 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Sun, 18 Jul 2021 14:41:22 -0700 Subject: [PATCH 26/42] Add fallback locations for join zones. It's rare with the current 5NM buffer around the origin, but if we use the hold distance as the buffer like we maybe should it's possible for the preferred join locations to fall entirely within the home zone. In that case, fall back to a location within the max-turn-zone that's outside the home zone and is nearest the IP. --- game/flightplan/joinzonegeometry.py | 32 +++++++++++++--------- gen/flights/flightplan.py | 1 - qt_ui/widgets/map/mapmodel.py | 42 ++++++++++++++++------------- resources/ui/map/map.js | 16 ++++++++++- 4 files changed, 59 insertions(+), 32 deletions(-) diff --git a/game/flightplan/joinzonegeometry.py b/game/flightplan/joinzonegeometry.py index 48cff780..02e00fa4 100644 --- a/game/flightplan/joinzonegeometry.py +++ b/game/flightplan/joinzonegeometry.py @@ -11,7 +11,6 @@ from shapely.geometry import ( MultiLineString, ) -from game.theater import ConflictTheater from game.utils import nautical_miles if TYPE_CHECKING: @@ -26,12 +25,7 @@ class JoinZoneGeometry: """ def __init__( - self, - target: Point, - home: Point, - ip: Point, - coalition: Coalition, - theater: ConflictTheater, + self, target: Point, home: Point, ip: Point, coalition: Coalition ) -> None: # Normal join placement is based on the path from home to the IP. If no path is # found it means that the target is on a direct path. In that case we instead @@ -82,14 +76,28 @@ class JoinZoneGeometry: ] ) - permissible_lines = ip_direction_limit_wedge.intersection( + permissible_zones = ip_direction_limit_wedge.difference( + self.excluded_zones + ).difference(self.home_bubble) + if permissible_zones.is_empty: + permissible_zones = MultiPolygon([]) + if not isinstance(permissible_zones, MultiPolygon): + permissible_zones = MultiPolygon([permissible_zones]) + self.permissible_zones = permissible_zones + + preferred_lines = ip_direction_limit_wedge.intersection( self.excluded_zones.boundary ).difference(self.home_bubble) - if not isinstance(permissible_lines, MultiLineString): - permissible_lines = MultiLineString([permissible_lines]) - self.permissible_lines = permissible_lines + if preferred_lines.is_empty: + preferred_lines = MultiLineString([]) + if not isinstance(preferred_lines, MultiLineString): + preferred_lines = MultiLineString([preferred_lines]) + self.preferred_lines = preferred_lines def find_best_join_point(self) -> Point: - join, _ = shapely.ops.nearest_points(self.permissible_lines, self.home) + if self.preferred_lines.is_empty: + join, _ = shapely.ops.nearest_points(self.permissible_zones, self.ip) + else: + join, _ = shapely.ops.nearest_points(self.preferred_lines, self.home) return Point(join.x, join.y) diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py index 6c25babd..fde43ee9 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -975,7 +975,6 @@ class FlightPlanBuilder: package_airfield.position, ingress_point, self.coalition, - self.theater, ).find_best_join_point() # And the split point based on the best route from the IP. Since that's no diff --git a/qt_ui/widgets/map/mapmodel.py b/qt_ui/widgets/map/mapmodel.py index e8a75298..fce7a5d9 100644 --- a/qt_ui/widgets/map/mapmodel.py +++ b/qt_ui/widgets/map/mapmodel.py @@ -869,7 +869,8 @@ class JoinZonesJs(QObject): targetBubbleChanged = Signal() ipBubbleChanged = Signal() excludedZonesChanged = Signal() - permissibleLinesChanged = Signal() + permissibleZonesChanged = Signal() + preferredLinesChanged = Signal() def __init__( self, @@ -877,14 +878,16 @@ class JoinZonesJs(QObject): target_bubble: LeafletPoly, ip_bubble: LeafletPoly, excluded_zones: list[LeafletPoly], - permissible_lines: list[list[LeafletLatLon]], + permissible_zones: list[LeafletPoly], + preferred_lines: list[list[LeafletLatLon]], ) -> None: super().__init__() self._home_bubble = home_bubble self._target_bubble = target_bubble self._ip_bubble = ip_bubble self._excluded_zones = excluded_zones - self._permissible_lines = permissible_lines + self._permissible_zones = permissible_zones + self._preferred_lines = preferred_lines @Property(list, notify=homeBubbleChanged) def homeBubble(self) -> LeafletPoly: @@ -902,13 +905,17 @@ class JoinZonesJs(QObject): def excludedZones(self) -> list[LeafletPoly]: return self._excluded_zones - @Property(list, notify=permissibleLinesChanged) - def permissibleLines(self) -> list[list[LeafletLatLon]]: - return self._permissible_lines + @Property(list, notify=permissibleZonesChanged) + def permissibleZones(self) -> list[LeafletPoly]: + return self._permissible_zones + + @Property(list, notify=preferredLinesChanged) + def preferredLines(self) -> list[list[LeafletLatLon]]: + return self._preferred_lines @classmethod def empty(cls) -> JoinZonesJs: - return JoinZonesJs([], [], [], [], []) + return JoinZonesJs([], [], [], [], [], []) @classmethod def for_flight(cls, flight: Flight, game: Game) -> JoinZonesJs: @@ -919,15 +926,14 @@ class JoinZonesJs(QObject): if flight.package.waypoints is None: return JoinZonesJs.empty() ip = flight.package.waypoints.ingress - geometry = JoinZoneGeometry( - target.position, home.position, ip, game.blue, game.theater - ) + geometry = JoinZoneGeometry(target.position, home.position, ip, game.blue) return JoinZonesJs( shapely_poly_to_leaflet_points(geometry.home_bubble, game.theater), shapely_poly_to_leaflet_points(geometry.target_bubble, game.theater), shapely_poly_to_leaflet_points(geometry.ip_bubble, game.theater), shapely_to_leaflet_polys(geometry.excluded_zones, game.theater), - shapely_lines_to_leaflet_points(geometry.permissible_lines, game.theater), + shapely_to_leaflet_polys(geometry.permissible_zones, game.theater), + shapely_lines_to_leaflet_points(geometry.preferred_lines, game.theater), ) @@ -937,7 +943,7 @@ class HoldZonesJs(QObject): joinBubbleChanged = Signal() excludedZonesChanged = Signal() permissibleZonesChanged = Signal() - permissibleLinesChanged = Signal() + preferredLinesChanged = Signal() def __init__( self, @@ -946,7 +952,7 @@ class HoldZonesJs(QObject): join_bubble: LeafletPoly, excluded_zones: list[LeafletPoly], permissible_zones: list[LeafletPoly], - permissible_lines: list[list[LeafletLatLon]], + preferred_lines: list[list[LeafletLatLon]], ) -> None: super().__init__() self._home_bubble = home_bubble @@ -954,7 +960,7 @@ class HoldZonesJs(QObject): self._join_bubble = join_bubble self._excluded_zones = excluded_zones self._permissible_zones = permissible_zones - self._permissible_lines = permissible_lines + self._preferred_lines = preferred_lines @Property(list, notify=homeBubbleChanged) def homeBubble(self) -> LeafletPoly: @@ -976,9 +982,9 @@ class HoldZonesJs(QObject): def permissibleZones(self) -> list[LeafletPoly]: return self._permissible_zones - @Property(list, notify=permissibleLinesChanged) - def permissibleLines(self) -> list[list[LeafletLatLon]]: - return self._permissible_lines + @Property(list, notify=preferredLinesChanged) + def preferredLines(self) -> list[list[LeafletLatLon]]: + return self._preferred_lines @classmethod def empty(cls) -> HoldZonesJs: @@ -1003,7 +1009,7 @@ class HoldZonesJs(QObject): shapely_poly_to_leaflet_points(geometry.join_bubble, game.theater), shapely_to_leaflet_polys(geometry.excluded_zones, game.theater), shapely_to_leaflet_polys(geometry.permissible_zones, game.theater), - [], # shapely_to_leaflet_polys(geometry.permissible_lines, game.theater), + shapely_lines_to_leaflet_points(geometry.preferred_lines, game.theater), ) diff --git a/resources/ui/map/map.js b/resources/ui/map/map.js index 831701a9..4e4ff59e 100644 --- a/resources/ui/map/map.js +++ b/resources/ui/map/map.js @@ -1066,7 +1066,14 @@ function drawJoinZones() { }).addTo(joinZones); } - for (const line of game.joinZones.permissibleLines) { + for (const zone of game.joinZones.permissibleZones) { + L.polygon(zone, { + color: Colors.Green, + interactive: false, + }).addTo(joinZones); + } + + for (const line of game.joinZones.preferredLines) { L.polyline(line, { color: Colors.Green, interactive: false, @@ -1114,6 +1121,13 @@ function drawHoldZones() { interactive: false, }).addTo(holdZones); } + + for (const line of game.holdZones.preferredLines) { + L.polyline(line, { + color: Colors.Green, + interactive: false, + }).addTo(holdZones); + } } function drawInitialMap() { From c2951e5e4131f33b91dc172a9d822755b6258528 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Sun, 18 Jul 2021 14:39:51 -0700 Subject: [PATCH 27/42] Increase minimum hold distance. The previous values were far too optimistic for a non-AB climb to hold altitude, especially for the AI. --- game/data/doctrine.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/game/data/doctrine.py b/game/data/doctrine.py index 359a1435..7ef7c59a 100644 --- a/game/data/doctrine.py +++ b/game/data/doctrine.py @@ -113,7 +113,7 @@ MODERN_DOCTRINE = Doctrine( strike=True, antiship=True, rendezvous_altitude=feet(25000), - hold_distance=nautical_miles(15), + hold_distance=nautical_miles(25), push_distance=nautical_miles(20), join_distance=nautical_miles(20), max_ingress_distance=nautical_miles(45), @@ -150,7 +150,7 @@ COLDWAR_DOCTRINE = Doctrine( strike=True, antiship=True, rendezvous_altitude=feet(22000), - hold_distance=nautical_miles(10), + hold_distance=nautical_miles(15), push_distance=nautical_miles(10), join_distance=nautical_miles(10), max_ingress_distance=nautical_miles(30), @@ -186,7 +186,7 @@ WWII_DOCTRINE = Doctrine( sead=False, strike=True, antiship=True, - hold_distance=nautical_miles(5), + hold_distance=nautical_miles(10), push_distance=nautical_miles(5), join_distance=nautical_miles(5), rendezvous_altitude=feet(10000), From 270f87f193652a6b6da3eae074ae1d099d174dd4 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Sun, 18 Jul 2021 15:49:58 -0700 Subject: [PATCH 28/42] Add per-aircraft tabs to air wing configuration. --- qt_ui/windows/AirWingConfigurationDialog.py | 176 ++++++++++++++------ 1 file changed, 127 insertions(+), 49 deletions(-) diff --git a/qt_ui/windows/AirWingConfigurationDialog.py b/qt_ui/windows/AirWingConfigurationDialog.py index fb6bdc8b..87ba13bb 100644 --- a/qt_ui/windows/AirWingConfigurationDialog.py +++ b/qt_ui/windows/AirWingConfigurationDialog.py @@ -1,21 +1,20 @@ -import itertools -import logging -from collections import defaultdict -from typing import Optional, Callable, Iterator +from typing import Optional, Callable from PySide2.QtCore import ( QItemSelectionModel, QModelIndex, QSize, Qt, + QItemSelection, + Signal, ) +from PySide2.QtGui import QStandardItemModel, QStandardItem, QIcon from PySide2.QtWidgets import ( QAbstractItemView, QDialog, QListView, QVBoxLayout, QGroupBox, - QGridLayout, QLabel, QWidget, QScrollArea, @@ -23,12 +22,15 @@ from PySide2.QtWidgets import ( QTextEdit, QCheckBox, QHBoxLayout, + QStackedLayout, ) from game import Game +from game.dcs.aircrafttype import AircraftType from game.squadrons import Squadron, AirWing, Pilot from gen.flights.flight import FlightType from qt_ui.models import AirWingModel, SquadronModel +from qt_ui.uiconstants import AIRCRAFT_ICONS from qt_ui.windows.AirWingDialog import SquadronDelegate from qt_ui.windows.SquadronDialog import SquadronDialog @@ -151,60 +153,33 @@ class SquadronConfigurationBox(QGroupBox): return self.squadron -class AirWingConfigurationLayout(QVBoxLayout): - def __init__(self, air_wing: AirWing) -> None: +class SquadronConfigurationLayout(QVBoxLayout): + def __init__(self, squadrons: list[Squadron]) -> None: super().__init__() - self.air_wing = air_wing self.squadron_configs = [] - - doc_url = ( - "https://github.com/dcs-liberation/dcs_liberation/wiki/Squadrons-and-pilots" - ) - doc_label = QLabel( - "Use this opportunity to customize the squadrons available to your " - "coalition. This is your
" - "only opportunity to make changes.

" - "
" - "To accept your changes and continue, close this window.
" - "
" - "To remove a squadron from the game, uncheck the box in the title. New " - "squadrons cannot
" - "be added via the UI at this time. To add a custom squadron, see " - f'the wiki.' - ) - - doc_label.setOpenExternalLinks(True) - self.addWidget(doc_label) - for squadron in self.air_wing.iter_squadrons(): + for squadron in squadrons: squadron_config = SquadronConfigurationBox(squadron) self.squadron_configs.append(squadron_config) self.addWidget(squadron_config) - def apply(self) -> None: - keep_squadrons = defaultdict(list) + def apply(self) -> list[Squadron]: + keep_squadrons = [] for squadron_config in self.squadron_configs: if squadron_config.isChecked(): - squadron = squadron_config.apply() - keep_squadrons[squadron.aircraft].append(squadron) - self.air_wing.squadrons = keep_squadrons + keep_squadrons.append(squadron_config.apply()) + return keep_squadrons -class AirWingConfigurationDialog(QDialog): - """Dialog window for air wing configuration.""" +class AircraftSquadronsPage(QWidget): + def __init__(self, squadrons: list[Squadron]) -> None: + super().__init__() + layout = QVBoxLayout() + self.setLayout(layout) - def __init__(self, game: Game, parent) -> None: - super().__init__(parent) - self.air_wing = game.blue.air_wing + self.squadrons_config = SquadronConfigurationLayout(squadrons) - self.setMinimumSize(500, 800) - self.setWindowTitle(f"Air Wing Configuration") - # TODO: self.setWindowIcon() - - self.air_wing_config = AirWingConfigurationLayout(self.air_wing) - - scrolling_layout = QVBoxLayout() scrolling_widget = QWidget() - scrolling_widget.setLayout(self.air_wing_config) + scrolling_widget.setLayout(self.squadrons_config) scrolling_area = QScrollArea() scrolling_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) @@ -212,9 +187,112 @@ class AirWingConfigurationDialog(QDialog): scrolling_area.setWidgetResizable(True) scrolling_area.setWidget(scrolling_widget) - scrolling_layout.addWidget(scrolling_area) - self.setLayout(scrolling_layout) + layout.addWidget(scrolling_area) + + def apply(self) -> list[Squadron]: + return self.squadrons_config.apply() + + +class AircraftSquadronsPanel(QStackedLayout): + def __init__(self, air_wing: AirWing) -> None: + super().__init__() + self.air_wing = air_wing + self.squadrons_pages: dict[AircraftType, AircraftSquadronsPage] = {} + for aircraft, squadrons in self.air_wing.squadrons.items(): + page = AircraftSquadronsPage(squadrons) + self.addWidget(page) + self.squadrons_pages[aircraft] = page + + def apply(self) -> None: + for aircraft, page in self.squadrons_pages.items(): + self.air_wing.squadrons[aircraft] = page.apply() + + +class AircraftTypeList(QListView): + page_index_changed = Signal(int) + + def __init__(self, air_wing: AirWing) -> None: + super().__init__() + self.setIconSize(QSize(91, 24)) + self.setMinimumWidth(300) + + model = QStandardItemModel(self) + self.setModel(model) + + self.selectionModel().setCurrentIndex( + model.index(0, 0), QItemSelectionModel.Select + ) + self.selectionModel().selectionChanged.connect(self.on_selection_changed) + for aircraft in air_wing.squadrons: + aircraft_item = QStandardItem(aircraft.name) + icon = self.icon_for(aircraft) + if icon is not None: + aircraft_item.setIcon(icon) + aircraft_item.setEditable(False) + aircraft_item.setSelectable(True) + model.appendRow(aircraft_item) + + def on_selection_changed( + self, selected: QItemSelection, _deselected: QItemSelection + ) -> None: + indexes = selected.indexes() + if len(indexes) > 1: + raise RuntimeError("Aircraft list should not allow multi-selection") + if not indexes: + return + self.page_index_changed.emit(indexes[0].row()) + + @staticmethod + def icon_for(aircraft: AircraftType) -> Optional[QIcon]: + name = aircraft.dcs_id + if name in AIRCRAFT_ICONS: + return QIcon(AIRCRAFT_ICONS[name]) + return None + + +class AirWingConfigurationDialog(QDialog): + """Dialog window for air wing configuration.""" + + def __init__(self, game: Game, parent) -> None: + super().__init__(parent) + self.setMinimumSize(500, 800) + self.setWindowTitle(f"Air Wing Configuration") + # TODO: self.setWindowIcon() + + layout = QVBoxLayout() + self.setLayout(layout) + + doc_url = ( + "https://github.com/dcs-liberation/dcs_liberation/wiki/Squadrons-and-pilots" + ) + doc_label = QLabel( + "Use this opportunity to customize the squadrons available to your " + "coalition. This is your only opportunity to make changes." + "

" + "To accept your changes and continue, close this window.
" + "
" + "To remove a squadron from the game, uncheck the box in the title. New " + "squadrons cannot be added via the UI at this time. To add a custom " + "squadron,
" + f'see the wiki.' + ) + + doc_label.setOpenExternalLinks(True) + layout.addWidget(doc_label) + + columns = QHBoxLayout() + layout.addLayout(columns) + + type_list = AircraftTypeList(game.blue.air_wing) + type_list.page_index_changed.connect(self.on_aircraft_changed) + columns.addWidget(type_list) + + self.squadrons_panel = AircraftSquadronsPanel(game.blue.air_wing) + columns.addLayout(self.squadrons_panel) def reject(self) -> None: - self.air_wing_config.apply() + self.squadrons_panel.apply() super().reject() + + def on_aircraft_changed(self, index: QModelIndex) -> None: + self.squadrons_panel.setCurrentIndex(index) From 0eb8ec70d96d7de132b86a0a5110936eb9a454b5 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Sun, 18 Jul 2021 16:09:20 -0700 Subject: [PATCH 29/42] Make opfor airwing configurable. --- qt_ui/windows/AirWingConfigurationDialog.py | 62 +++++++++++++++------ 1 file changed, 45 insertions(+), 17 deletions(-) diff --git a/qt_ui/windows/AirWingConfigurationDialog.py b/qt_ui/windows/AirWingConfigurationDialog.py index 87ba13bb..85539d06 100644 --- a/qt_ui/windows/AirWingConfigurationDialog.py +++ b/qt_ui/windows/AirWingConfigurationDialog.py @@ -23,6 +23,7 @@ from PySide2.QtWidgets import ( QCheckBox, QHBoxLayout, QStackedLayout, + QTabWidget, ) from game import Game @@ -118,14 +119,22 @@ class SquadronConfigurationBox(QGroupBox): self.nickname_edit.textChanged.connect(self.on_nickname_changed) left_column.addWidget(self.nickname_edit) - left_column.addWidget( - QLabel("Players (one per line, leave empty for an AI-only squadron):") - ) - players = [p for p in squadron.available_pilots if p.player] + if squadron.player: + player_label = QLabel( + "Players (one per line, leave empty for an AI-only squadron):" + ) + else: + player_label = QLabel("Player slots not available for opfor") + left_column.addWidget(player_label) + + players = [p for p in squadron.pilot_pool if p.player] for player in players: - squadron.available_pilots.remove(player) + squadron.pilot_pool.remove(player) + if not squadron.player: + players = [] self.player_list = QTextEdit("
".join(p.name for p in players)) self.player_list.setAcceptRichText(False) + self.player_list.setEnabled(squadron.player) left_column.addWidget(self.player_list) left_column.addStretch() @@ -250,6 +259,27 @@ class AircraftTypeList(QListView): return None +class AirWingConfigurationTab(QWidget): + def __init__(self, air_wing: AirWing) -> None: + super().__init__() + + layout = QHBoxLayout() + self.setLayout(layout) + + type_list = AircraftTypeList(air_wing) + type_list.page_index_changed.connect(self.on_aircraft_changed) + layout.addWidget(type_list) + + self.squadrons_panel = AircraftSquadronsPanel(air_wing) + layout.addLayout(self.squadrons_panel) + + def apply(self) -> None: + self.squadrons_panel.apply() + + def on_aircraft_changed(self, index: QModelIndex) -> None: + self.squadrons_panel.setCurrentIndex(index) + + class AirWingConfigurationDialog(QDialog): """Dialog window for air wing configuration.""" @@ -280,19 +310,17 @@ class AirWingConfigurationDialog(QDialog): doc_label.setOpenExternalLinks(True) layout.addWidget(doc_label) - columns = QHBoxLayout() - layout.addLayout(columns) + tab_widget = QTabWidget() + layout.addWidget(tab_widget) - type_list = AircraftTypeList(game.blue.air_wing) - type_list.page_index_changed.connect(self.on_aircraft_changed) - columns.addWidget(type_list) - - self.squadrons_panel = AircraftSquadronsPanel(game.blue.air_wing) - columns.addLayout(self.squadrons_panel) + self.tabs = [] + for coalition in game.coalitions: + coalition_tab = AirWingConfigurationTab(coalition.air_wing) + name = "Blue" if coalition.player else "Red" + tab_widget.addTab(coalition_tab, name) + self.tabs.append(coalition_tab) def reject(self) -> None: - self.squadrons_panel.apply() + for tab in self.tabs: + tab.apply() super().reject() - - def on_aircraft_changed(self, index: QModelIndex) -> None: - self.squadrons_panel.setCurrentIndex(index) From ce01ad2083118597368c871344e3e5bc0acdf7df Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Sun, 18 Jul 2021 17:12:34 -0700 Subject: [PATCH 30/42] Default to aircraft at only appropriate bases. --- changelog.md | 1 + game/commander/aircraftallocator.py | 4 ++- game/procurement.py | 6 ++++- game/squadrons.py | 40 +++++++++++++++++++++++++++++ 4 files changed, 49 insertions(+), 2 deletions(-) diff --git a/changelog.md b/changelog.md index 0d4174d9..203eaeac 100644 --- a/changelog.md +++ b/changelog.md @@ -10,6 +10,7 @@ Saves from 3.x are not compatible with 5.0. * **[Campaign AI]** Player front line stances can now be automated. Improved stance selection for AI. * **[Campaign AI]** Reworked layout of hold, join, split, and ingress points. Should result in much shorter flight plans in general while still maintaining safe join/split/hold points. * **[Campaign AI]** Auto-planning mission range limits are now specified per-aircraft. On average this means that longer range missions will now be plannable. The limit only accounts for the direct distance to the target, not the path taken. +* **[Campaign AI]** Aircraft will now only be automatically purchased or assigned at appropriate bases. Naval aircraft will default to only operating from carriers, Harriers will default to LHAs and shore bases, helicopters will operate from anywhere. This can be customized per-squadron. * **[Kneeboard]** Minimum required fuel estimates have been added to the kneeboard for aircraft with supporting data (currently only the Hornet). * **[New Game Wizard]** Can now customize the player's air wing before campaign start to disable or rename squadrons. diff --git a/game/commander/aircraftallocator.py b/game/commander/aircraftallocator.py index 523fad64..a50dbd22 100644 --- a/game/commander/aircraftallocator.py +++ b/game/commander/aircraftallocator.py @@ -70,7 +70,9 @@ class AircraftAllocator: aircraft, task ) for squadron in squadrons: - if squadron.can_provide_pilots(flight.num_aircraft): + if squadron.operates_from(airfield) and squadron.can_provide_pilots( + flight.num_aircraft + ): inventory.remove_aircraft(aircraft, flight.num_aircraft) return airfield, squadron return None diff --git a/game/procurement.py b/game/procurement.py index 3b1ea370..d1c254f0 100644 --- a/game/procurement.py +++ b/game/procurement.py @@ -226,7 +226,11 @@ class ProcurementAi: continue for squadron in self.air_wing.squadrons_for(unit): - if request.task_capability in squadron.auto_assignable_mission_types: + if ( + squadron.operates_from(airbase) + and request.task_capability + in squadron.auto_assignable_mission_types + ): break else: continue diff --git a/game/squadrons.py b/game/squadrons.py index 45ebe7de..5777102f 100644 --- a/game/squadrons.py +++ b/game/squadrons.py @@ -1,5 +1,6 @@ from __future__ import annotations +import dataclasses import itertools import logging import random @@ -26,6 +27,7 @@ if TYPE_CHECKING: from game import Game from game.coalition import Coalition from gen.flights.flight import FlightType + from game.theater import ControlPoint @dataclass @@ -73,6 +75,33 @@ class Pilot: return Pilot(faker.name()) +@dataclass(frozen=True) +class OperatingBases: + shore: bool + carrier: bool + lha: bool + + @classmethod + def default_for_aircraft(cls, aircraft: AircraftType) -> OperatingBases: + if aircraft.dcs_unit_type.helicopter: + # Helicopters operate from anywhere by default. + return OperatingBases(shore=True, carrier=True, lha=True) + if aircraft.lha_capable: + # Marine aircraft operate from LHAs and the shore by default. + return OperatingBases(shore=True, carrier=False, lha=True) + if aircraft.carrier_capable: + # Carrier aircraft operate from carriers by default. + return OperatingBases(shore=False, carrier=True, lha=False) + # And the rest are only capable of shore operation. + return OperatingBases(shore=True, carrier=False, lha=False) + + @classmethod + def from_yaml(cls, aircraft: AircraftType, data: dict[str, bool]) -> OperatingBases: + return dataclasses.replace( + OperatingBases.default_for_aircraft(aircraft), **data + ) + + @dataclass class Squadron: name: str @@ -82,6 +111,7 @@ class Squadron: aircraft: AircraftType livery: Optional[str] mission_types: tuple[FlightType, ...] + operating_bases: OperatingBases #: The pool of pilots that have not yet been assigned to the squadron. This only #: happens when a preset squadron defines more preset pilots than the squadron limit @@ -252,6 +282,14 @@ class Squadron: def can_auto_assign(self, task: FlightType) -> bool: return task in self.auto_assignable_mission_types + def operates_from(self, control_point: ControlPoint) -> bool: + if control_point.is_carrier: + return self.operating_bases.carrier + elif control_point.is_lha: + return self.operating_bases.lha + else: + return self.operating_bases.shore + def pilot_at_index(self, index: int) -> Pilot: return self.current_roster[index] @@ -290,6 +328,7 @@ class Squadron: aircraft=unit_type, livery=data.get("livery"), mission_types=tuple(mission_types), + operating_bases=OperatingBases.from_yaml(unit_type, data.get("bases", {})), pilot_pool=pilots, coalition=coalition, settings=game.settings, @@ -379,6 +418,7 @@ class AirWing: aircraft=aircraft, livery=None, mission_types=tuple(tasks_for_aircraft(aircraft)), + operating_bases=OperatingBases.default_for_aircraft(aircraft), pilot_pool=[], coalition=coalition, settings=game.settings, From c9b6b5d4a8474ebcb9eccd4e2cfea73ec3d9f3ee Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Sun, 18 Jul 2021 19:38:55 -0700 Subject: [PATCH 31/42] Correct changelog. --- changelog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.md b/changelog.md index 203eaeac..d8d0cc36 100644 --- a/changelog.md +++ b/changelog.md @@ -1,6 +1,6 @@ # 5.0.0 -Saves from 3.x are not compatible with 5.0. +Saves from 4.x are not compatible with 5.0. ## Features/Improvements From e87aa8366607a6f7c28c0b65323319a365e1be23 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Mon, 19 Jul 2021 16:27:20 -0700 Subject: [PATCH 32/42] Add CLI generator options for date restrictions. --- qt_ui/main.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/qt_ui/main.py b/qt_ui/main.py index ee614287..28288c43 100644 --- a/qt_ui/main.py +++ b/qt_ui/main.py @@ -183,6 +183,19 @@ def parse_args() -> argparse.Namespace: "--inverted", action="store_true", help="Invert the campaign." ) + new_game.add_argument( + "--date", + type=datetime.fromisoformat, + default=datetime.today(), + help="Start date of the campaign.", + ) + + new_game.add_argument( + "--restrict-weapons-by-date", + action="store_true", + help="Enable campaign date restricted weapons.", + ) + new_game.add_argument("--cheats", action="store_true", help="Enable cheats.") return parser.parse_args() @@ -196,6 +209,8 @@ def create_game( auto_procurement: bool, inverted: bool, cheats: bool, + start_date: datetime, + restrict_weapons_by_date: bool, ) -> Game: first_start = liberation_install.init() if first_start: @@ -224,9 +239,10 @@ def create_game( automate_aircraft_reinforcements=auto_procurement, enable_frontline_cheats=cheats, enable_base_capture_cheat=cheats, + restrict_weapons_by_date=restrict_weapons_by_date, ), GeneratorSettings( - start_date=datetime.today(), + start_date=start_date, player_budget=DEFAULT_BUDGET, enemy_budget=DEFAULT_BUDGET, midgame=False, @@ -279,6 +295,8 @@ def main(): args.auto_procurement, args.inverted, args.cheats, + args.date, + args.restrict_weapons_by_date, ) run_ui(game) From 5e2ed04d728986843853b74ad508de29737d3b1d Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Mon, 19 Jul 2021 16:47:43 -0700 Subject: [PATCH 33/42] Add weapon data for the CBU-87 and CBU-97. --- resources/weapons/bombs/CBU-87-2X.yaml | 6 ++++++ resources/weapons/bombs/CBU-87-3X.yaml | 5 +++++ resources/weapons/bombs/CBU-87.yaml | 5 +++++ resources/weapons/bombs/CBU-97-2X.yaml | 6 ++++++ resources/weapons/bombs/CBU-97-3X.yaml | 5 +++++ resources/weapons/bombs/CBU-97.yaml | 5 +++++ 6 files changed, 32 insertions(+) create mode 100644 resources/weapons/bombs/CBU-87-2X.yaml create mode 100644 resources/weapons/bombs/CBU-87-3X.yaml create mode 100644 resources/weapons/bombs/CBU-87.yaml create mode 100644 resources/weapons/bombs/CBU-97-2X.yaml create mode 100644 resources/weapons/bombs/CBU-97-3X.yaml create mode 100644 resources/weapons/bombs/CBU-97.yaml diff --git a/resources/weapons/bombs/CBU-87-2X.yaml b/resources/weapons/bombs/CBU-87-2X.yaml new file mode 100644 index 00000000..7bfc2840 --- /dev/null +++ b/resources/weapons/bombs/CBU-87-2X.yaml @@ -0,0 +1,6 @@ +name: 2xCBU-87 +year: 1986 +fallback: 2xMk 82 +clsids: + - "{TER_9A_2L*CBU-87}" + - "{TER_9A_2R*CBU-87}" diff --git a/resources/weapons/bombs/CBU-87-3X.yaml b/resources/weapons/bombs/CBU-87-3X.yaml new file mode 100644 index 00000000..34fab5c3 --- /dev/null +++ b/resources/weapons/bombs/CBU-87-3X.yaml @@ -0,0 +1,5 @@ +name: 3xCBU-87 +year: 1986 +fallback: 3xMk 82 +clsids: + - "{TER_9A_3*CBU-87}" diff --git a/resources/weapons/bombs/CBU-87.yaml b/resources/weapons/bombs/CBU-87.yaml new file mode 100644 index 00000000..1f7257dc --- /dev/null +++ b/resources/weapons/bombs/CBU-87.yaml @@ -0,0 +1,5 @@ +name: CBU-87 +year: 1986 +fallback: Mk 82 +clsids: + - "{CBU-87}" diff --git a/resources/weapons/bombs/CBU-97-2X.yaml b/resources/weapons/bombs/CBU-97-2X.yaml new file mode 100644 index 00000000..a5019110 --- /dev/null +++ b/resources/weapons/bombs/CBU-97-2X.yaml @@ -0,0 +1,6 @@ +name: 2xCBU-97 +year: 1992 +fallback: 2xCBU-87 +clsids: + - "{TER_9A_2L*CBU-97}" + - "{TER_9A_2R*CBU-97}" diff --git a/resources/weapons/bombs/CBU-97-3X.yaml b/resources/weapons/bombs/CBU-97-3X.yaml new file mode 100644 index 00000000..35aac1dd --- /dev/null +++ b/resources/weapons/bombs/CBU-97-3X.yaml @@ -0,0 +1,5 @@ +name: 3xCBU-97 +year: 1992 +fallback: 3xCBU-87 +clsids: + - "{TER_9A_3*CBU-97}" diff --git a/resources/weapons/bombs/CBU-97.yaml b/resources/weapons/bombs/CBU-97.yaml new file mode 100644 index 00000000..57527755 --- /dev/null +++ b/resources/weapons/bombs/CBU-97.yaml @@ -0,0 +1,5 @@ +name: CBU-97 +year: 1992 +fallback: CBU-87 +clsids: + - "{5335D97A-35A5-4643-9D9B-026C75961E52}" From fab550157a7d5b255792e2320f40257a4f46f722 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Mon, 19 Jul 2021 20:07:58 -0700 Subject: [PATCH 34/42] Add a per-aircraft weapon linter. Run with `main.py lint-weapons $AIRCRAFT` to show all the weapons the aircraft can carry that do not have data. --- qt_ui/main.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/qt_ui/main.py b/qt_ui/main.py index 28288c43..70d4dd5b 100644 --- a/qt_ui/main.py +++ b/qt_ui/main.py @@ -13,8 +13,9 @@ from PySide2.QtWidgets import QApplication, QSplashScreen from dcs.payloads import PayloadDirectories from game import Game, VERSION, persistency -from game.data.weapons import WeaponGroup +from game.data.weapons import WeaponGroup, Pylon, Weapon from game.db import FACTIONS +from game.dcs.aircrafttype import AircraftType from game.profiling import logged_duration from game.settings import Settings from game.theater.start_generator import GameGenerator, GeneratorSettings, ModSettings @@ -198,6 +199,9 @@ def parse_args() -> argparse.Namespace: new_game.add_argument("--cheats", action="store_true", help="Enable cheats.") + lint_weapons = subparsers.add_parser("lint-weapons") + lint_weapons.add_argument("aircraft", help="Name of the aircraft variant to lint.") + return parser.parse_args() @@ -267,11 +271,21 @@ def create_game( return game -def lint_weapon_data() -> None: +def lint_all_weapon_data() -> None: for weapon in WeaponGroup.named("Unknown").weapons: logging.warning(f"No weapon data for {weapon}: {weapon.clsid}") +def lint_weapon_data_for_aircraft(aircraft: AircraftType) -> None: + all_weapons: set[Weapon] = set() + for pylon in Pylon.iter_pylons(aircraft): + all_weapons |= pylon.allowed + + for weapon in all_weapons: + if weapon.weapon_group.name == "Unknown": + logging.warning(f'{weapon.clsid} "{weapon.name}" has no weapon data') + + def main(): logging_config.init_logging(VERSION) @@ -283,7 +297,7 @@ def main(): # TODO: Flesh out data and then make unconditional. if args.warn_missing_weapon_data: - lint_weapon_data() + lint_all_weapon_data() if args.subcommand == "new-game": with logged_duration("New game creation"): @@ -298,6 +312,9 @@ def main(): args.date, args.restrict_weapons_by_date, ) + if args.subcommand == "lint-weapons": + lint_weapon_data_for_aircraft(AircraftType.named(args.aircraft)) + return run_ui(game) From 91d430085e6bc40234414339ebf17991f4ebb8f7 Mon Sep 17 00:00:00 2001 From: bgreman <47828384+bgreman@users.noreply.github.com> Date: Wed, 21 Jul 2021 10:29:37 -0400 Subject: [PATCH 35/42] Addresses #478, adding a heading class to represent headings and angles (#1387) * Addresses #478, adding a heading class to represent headings and angles Removed some unused code * Fixing bad merge * Formatting * Fixing type issues and other merge resolution misses --- game/point_with_heading.py | 5 +- game/theater/conflicttheater.py | 58 ++++++++++++++------ game/theater/controlpoint.py | 31 ++++++----- game/theater/frontline.py | 12 ++--- game/theater/start_generator.py | 5 +- game/theater/theatergroundobject.py | 26 ++++----- game/utils.py | 64 ++++++++++++++++++---- gen/aircraft.py | 2 +- gen/airsupportgen.py | 7 ++- gen/armor.py | 84 ++++++++++++++--------------- gen/coastal/silkworm.py | 3 +- gen/conflictgen.py | 34 ++++++------ gen/fleet/carrier_group.py | 3 +- gen/flights/flightplan.py | 47 ++++++++-------- gen/groundobjectsgen.py | 24 +++++---- gen/missiles/scud_site.py | 3 +- gen/missiles/v1_group.py | 3 +- gen/runways.py | 21 ++++---- gen/sam/aaa_flak.py | 3 +- gen/sam/aaa_ww2_ally_flak.py | 9 ++-- gen/sam/cold_war_flak.py | 8 +-- gen/sam/freya_ewr.py | 3 +- gen/sam/group_generator.py | 24 ++++----- gen/visualgen.py | 2 +- qt_ui/widgets/map/mapmodel.py | 4 +- 25 files changed, 296 insertions(+), 189 deletions(-) diff --git a/game/point_with_heading.py b/game/point_with_heading.py index a87914a1..7eed4da2 100644 --- a/game/point_with_heading.py +++ b/game/point_with_heading.py @@ -1,15 +1,16 @@ from __future__ import annotations from dcs import Point +from game.utils import Heading class PointWithHeading(Point): def __init__(self) -> None: super(PointWithHeading, self).__init__(0, 0) - self.heading = 0 + self.heading: Heading = Heading.from_degrees(0) @staticmethod - def from_point(point: Point, heading: int) -> PointWithHeading: + def from_point(point: Point, heading: Heading) -> PointWithHeading: p = PointWithHeading() p.x = point.x p.y = point.y diff --git a/game/theater/conflicttheater.py b/game/theater/conflicttheater.py index 95e53ac9..8e88bda2 100644 --- a/game/theater/conflicttheater.py +++ b/game/theater/conflicttheater.py @@ -59,7 +59,7 @@ from ..point_with_heading import PointWithHeading from ..positioned import Positioned from ..profiling import logged_duration from ..scenery_group import SceneryGroup -from ..utils import Distance, meters +from ..utils import Distance, Heading, meters if TYPE_CHECKING: from . import TheaterGroundObject @@ -400,85 +400,113 @@ class MizCampaignLoader: for static in self.offshore_strike_targets: closest, distance = self.objective_info(static) closest.preset_locations.offshore_strike_locations.append( - PointWithHeading.from_point(static.position, static.units[0].heading) + PointWithHeading.from_point( + static.position, Heading.from_degrees(static.units[0].heading) + ) ) for ship in self.ships: closest, distance = self.objective_info(ship, allow_naval=True) closest.preset_locations.ships.append( - PointWithHeading.from_point(ship.position, ship.units[0].heading) + PointWithHeading.from_point( + ship.position, Heading.from_degrees(ship.units[0].heading) + ) ) for group in self.missile_sites: closest, distance = self.objective_info(group) closest.preset_locations.missile_sites.append( - PointWithHeading.from_point(group.position, group.units[0].heading) + PointWithHeading.from_point( + group.position, Heading.from_degrees(group.units[0].heading) + ) ) for group in self.coastal_defenses: closest, distance = self.objective_info(group) closest.preset_locations.coastal_defenses.append( - PointWithHeading.from_point(group.position, group.units[0].heading) + PointWithHeading.from_point( + group.position, Heading.from_degrees(group.units[0].heading) + ) ) for group in self.long_range_sams: closest, distance = self.objective_info(group) closest.preset_locations.long_range_sams.append( - PointWithHeading.from_point(group.position, group.units[0].heading) + PointWithHeading.from_point( + group.position, Heading.from_degrees(group.units[0].heading) + ) ) for group in self.medium_range_sams: closest, distance = self.objective_info(group) closest.preset_locations.medium_range_sams.append( - PointWithHeading.from_point(group.position, group.units[0].heading) + PointWithHeading.from_point( + group.position, Heading.from_degrees(group.units[0].heading) + ) ) for group in self.short_range_sams: closest, distance = self.objective_info(group) closest.preset_locations.short_range_sams.append( - PointWithHeading.from_point(group.position, group.units[0].heading) + PointWithHeading.from_point( + group.position, Heading.from_degrees(group.units[0].heading) + ) ) for group in self.aaa: closest, distance = self.objective_info(group) closest.preset_locations.aaa.append( - PointWithHeading.from_point(group.position, group.units[0].heading) + PointWithHeading.from_point( + group.position, Heading.from_degrees(group.units[0].heading) + ) ) for group in self.ewrs: closest, distance = self.objective_info(group) closest.preset_locations.ewrs.append( - PointWithHeading.from_point(group.position, group.units[0].heading) + PointWithHeading.from_point( + group.position, Heading.from_degrees(group.units[0].heading) + ) ) for group in self.armor_groups: closest, distance = self.objective_info(group) closest.preset_locations.armor_groups.append( - PointWithHeading.from_point(group.position, group.units[0].heading) + PointWithHeading.from_point( + group.position, Heading.from_degrees(group.units[0].heading) + ) ) for static in self.helipads: closest, distance = self.objective_info(static) closest.helipads.append( - PointWithHeading.from_point(static.position, static.units[0].heading) + PointWithHeading.from_point( + static.position, Heading.from_degrees(static.units[0].heading) + ) ) for static in self.factories: closest, distance = self.objective_info(static) closest.preset_locations.factories.append( - PointWithHeading.from_point(static.position, static.units[0].heading) + PointWithHeading.from_point( + static.position, Heading.from_degrees(static.units[0].heading) + ) ) for static in self.ammunition_depots: closest, distance = self.objective_info(static) closest.preset_locations.ammunition_depots.append( - PointWithHeading.from_point(static.position, static.units[0].heading) + PointWithHeading.from_point( + static.position, Heading.from_degrees(static.units[0].heading) + ) ) for static in self.strike_targets: closest, distance = self.objective_info(static) closest.preset_locations.strike_locations.append( - PointWithHeading.from_point(static.position, static.units[0].heading) + PointWithHeading.from_point( + static.position, Heading.from_degrees(static.units[0].heading) + ) ) for scenery_group in self.scenery: diff --git a/game/theater/controlpoint.py b/game/theater/controlpoint.py index 075f4f5e..12786d32 100644 --- a/game/theater/controlpoint.py +++ b/game/theater/controlpoint.py @@ -35,6 +35,7 @@ from dcs.unit import Unit from game import db from game.point_with_heading import PointWithHeading from game.scenery_group import SceneryGroup +from game.utils import Heading from gen.flights.closestairfields import ObjectiveDistanceCache from gen.ground_forces.combat_stance import CombatStance from gen.runways import RunwayAssigner, RunwayData @@ -335,7 +336,7 @@ class ControlPoint(MissionTarget, ABC): @property @abstractmethod - def heading(self) -> int: + def heading(self) -> Heading: ... def __str__(self) -> str: @@ -838,8 +839,8 @@ class Airfield(ControlPoint): return len(self.airport.parking_slots) @property - def heading(self) -> int: - return self.airport.runways[0].heading + def heading(self) -> Heading: + return Heading.from_degrees(self.airport.runways[0].heading) def runway_is_operational(self) -> bool: return not self.runway_status.damaged @@ -903,8 +904,8 @@ class NavalControlPoint(ControlPoint, ABC): yield from super().mission_types(for_player) @property - def heading(self) -> int: - return 0 # TODO compute heading + def heading(self) -> Heading: + return Heading.from_degrees(0) # TODO compute heading def find_main_tgo(self) -> GenericCarrierGroundObject: for g in self.ground_objects: @@ -933,7 +934,9 @@ class NavalControlPoint(ControlPoint, ABC): self, conditions: Conditions, dynamic_runways: Dict[str, RunwayData] ) -> RunwayData: # TODO: Assign TACAN and ICLS earlier so we don't need this. - fallback = RunwayData(self.full_name, runway_heading=0, runway_name="") + fallback = RunwayData( + self.full_name, runway_heading=Heading.from_degrees(0), runway_name="" + ) return dynamic_runways.get(self.name, fallback) @property @@ -1071,14 +1074,16 @@ class OffMapSpawn(ControlPoint): return True @property - def heading(self) -> int: - return 0 + def heading(self) -> Heading: + return Heading.from_degrees(0) def active_runway( self, conditions: Conditions, dynamic_runways: Dict[str, RunwayData] ) -> RunwayData: logging.warning("TODO: Off map spawns have no runways.") - return RunwayData(self.full_name, runway_heading=0, runway_name="") + return RunwayData( + self.full_name, runway_heading=Heading.from_degrees(0), runway_name="" + ) @property def runway_status(self) -> RunwayStatus: @@ -1120,7 +1125,9 @@ class Fob(ControlPoint): self, conditions: Conditions, dynamic_runways: Dict[str, RunwayData] ) -> RunwayData: logging.warning("TODO: FOBs have no runways.") - return RunwayData(self.full_name, runway_heading=0, runway_name="") + return RunwayData( + self.full_name, runway_heading=Heading.from_degrees(0), runway_name="" + ) @property def runway_status(self) -> RunwayStatus: @@ -1142,8 +1149,8 @@ class Fob(ControlPoint): return False @property - def heading(self) -> int: - return 0 + def heading(self) -> Heading: + return Heading.from_degrees(0) @property def can_deploy_ground_units(self) -> bool: diff --git a/game/theater/frontline.py b/game/theater/frontline.py index 2f1b6067..98aa88f6 100644 --- a/game/theater/frontline.py +++ b/game/theater/frontline.py @@ -11,7 +11,7 @@ from .controlpoint import ( ControlPoint, MissionTarget, ) -from ..utils import pairwise +from ..utils import Heading, pairwise FRONTLINE_MIN_CP_DISTANCE = 5000 @@ -27,9 +27,9 @@ class FrontLineSegment: point_b: Point @property - def attack_heading(self) -> float: + def attack_heading(self) -> Heading: """The heading of the frontline segment from player to enemy control point""" - return self.point_a.heading_between_point(self.point_b) + return Heading.from_degrees(self.point_a.heading_between_point(self.point_b)) @property def attack_distance(self) -> float: @@ -123,7 +123,7 @@ class FrontLine(MissionTarget): return sum(i.attack_distance for i in self.segments) @property - def attack_heading(self) -> float: + def attack_heading(self) -> Heading: """The heading of the active attack segment from player to enemy control point""" return self.active_segment.attack_heading @@ -150,13 +150,13 @@ class FrontLine(MissionTarget): """ if distance < self.segments[0].attack_distance: return self.blue_cp.position.point_from_heading( - self.segments[0].attack_heading, distance + self.segments[0].attack_heading.degrees, distance ) remaining_dist = distance for segment in self.segments: if remaining_dist < segment.attack_distance: return segment.point_a.point_from_heading( - segment.attack_heading, remaining_dist + segment.attack_heading.degrees, remaining_dist ) else: remaining_dist -= segment.attack_distance diff --git a/game/theater/start_generator.py b/game/theater/start_generator.py index aee758e9..61cc25af 100644 --- a/game/theater/start_generator.py +++ b/game/theater/start_generator.py @@ -28,6 +28,7 @@ from game.theater.theatergroundobject import ( VehicleGroupGroundObject, CoastalSiteGroundObject, ) +from game.utils import Heading from game.version import VERSION from gen import namegen from gen.coastal.coastal_group_generator import generate_coastal_group @@ -385,7 +386,7 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator): group_id, object_id, position + template_point, - unit["heading"], + Heading.from_degrees(unit["heading"]), self.control_point, unit["type"], ) @@ -585,7 +586,7 @@ class FobGroundObjectGenerator(AirbaseGroundObjectGenerator): group_id, object_id, point + template_point, - unit["heading"], + Heading.from_degrees(unit["heading"]), self.control_point, unit["type"], is_fob_structure=True, diff --git a/game/theater/theatergroundobject.py b/game/theater/theatergroundobject.py index f063a1ea..d3bfae64 100644 --- a/game/theater/theatergroundobject.py +++ b/game/theater/theatergroundobject.py @@ -17,7 +17,7 @@ from ..data.radar_db import ( TELARS, LAUNCHER_TRACKER_PAIRS, ) -from ..utils import Distance, meters +from ..utils import Distance, Heading, meters if TYPE_CHECKING: from .controlpoint import ControlPoint @@ -58,7 +58,7 @@ class TheaterGroundObject(MissionTarget, Generic[GroupT]): category: str, group_id: int, position: Point, - heading: int, + heading: Heading, control_point: ControlPoint, dcs_identifier: str, sea_object: bool, @@ -222,7 +222,7 @@ class BuildingGroundObject(TheaterGroundObject[VehicleGroup]): group_id: int, object_id: int, position: Point, - heading: int, + heading: Heading, control_point: ControlPoint, dcs_identifier: str, is_fob_structure: bool = False, @@ -310,7 +310,7 @@ class SceneryGroundObject(BuildingGroundObject): group_id=group_id, object_id=object_id, position=position, - heading=0, + heading=Heading.from_degrees(0), control_point=control_point, dcs_identifier=dcs_identifier, is_fob_structure=False, @@ -334,7 +334,7 @@ class FactoryGroundObject(BuildingGroundObject): name: str, group_id: int, position: Point, - heading: int, + heading: Heading, control_point: ControlPoint, ) -> None: super().__init__( @@ -385,7 +385,7 @@ class CarrierGroundObject(GenericCarrierGroundObject): category="CARRIER", group_id=group_id, position=control_point.position, - heading=0, + heading=Heading.from_degrees(0), control_point=control_point, dcs_identifier="CARRIER", sea_object=True, @@ -406,7 +406,7 @@ class LhaGroundObject(GenericCarrierGroundObject): category="LHA", group_id=group_id, position=control_point.position, - heading=0, + heading=Heading.from_degrees(0), control_point=control_point, dcs_identifier="LHA", sea_object=True, @@ -428,7 +428,7 @@ class MissileSiteGroundObject(TheaterGroundObject[VehicleGroup]): category="missile", group_id=group_id, position=position, - heading=0, + heading=Heading.from_degrees(0), control_point=control_point, dcs_identifier="AA", sea_object=False, @@ -450,7 +450,7 @@ class CoastalSiteGroundObject(TheaterGroundObject[VehicleGroup]): group_id: int, position: Point, control_point: ControlPoint, - heading: int, + heading: Heading, ) -> None: super().__init__( name=name, @@ -497,7 +497,7 @@ class SamGroundObject(IadsGroundObject): category="aa", group_id=group_id, position=position, - heading=0, + heading=Heading.from_degrees(0), control_point=control_point, dcs_identifier="AA", sea_object=False, @@ -565,7 +565,7 @@ class VehicleGroupGroundObject(TheaterGroundObject[VehicleGroup]): category="armor", group_id=group_id, position=position, - heading=0, + heading=Heading.from_degrees(0), control_point=control_point, dcs_identifier="AA", sea_object=False, @@ -593,7 +593,7 @@ class EwrGroundObject(IadsGroundObject): category="ewr", group_id=group_id, position=position, - heading=0, + heading=Heading.from_degrees(0), control_point=control_point, dcs_identifier="EWR", sea_object=False, @@ -627,7 +627,7 @@ class ShipGroundObject(NavalGroundObject): category="ship", group_id=group_id, position=position, - heading=0, + heading=Heading.from_degrees(0), control_point=control_point, dcs_identifier="AA", sea_object=True, diff --git a/game/utils.py b/game/utils.py index b21b11df..119a741a 100644 --- a/game/utils.py +++ b/game/utils.py @@ -2,6 +2,7 @@ from __future__ import annotations import itertools import math +import random from collections import Iterable from dataclasses import dataclass from typing import Union, Any, TypeVar @@ -20,15 +21,6 @@ INHG_TO_HPA = 33.86389 INHG_TO_MMHG = 25.400002776728 -def heading_sum(h: int, a: int) -> int: - h += a - return h % 360 - - -def opposite_heading(h: int) -> int: - return heading_sum(h, 180) - - @dataclass(frozen=True, order=True) class Distance: distance_in_meters: float @@ -184,6 +176,60 @@ def mach(value: float, altitude: Distance) -> Speed: SPEED_OF_SOUND_AT_SEA_LEVEL = knots(661.5) +@dataclass(frozen=True, order=True) +class Heading: + heading_in_degrees: int + + @property + def degrees(self) -> int: + return Heading.reduce_angle(self.heading_in_degrees) + + @property + def radians(self) -> float: + return math.radians(Heading.reduce_angle(self.heading_in_degrees)) + + @property + def opposite(self) -> Heading: + return self + Heading.from_degrees(180) + + @property + def right(self) -> Heading: + return self + Heading.from_degrees(90) + + @property + def left(self) -> Heading: + return self - Heading.from_degrees(90) + + def angle_between(self, other: Heading) -> Heading: + angle_between = abs(self.degrees - other.degrees) + if angle_between > 180: + angle_between = 360 - angle_between + return Heading.from_degrees(angle_between) + + @staticmethod + def reduce_angle(angle: int) -> int: + return angle % 360 + + @classmethod + def from_degrees(cls, angle: Union[int, float]) -> Heading: + return cls(Heading.reduce_angle(round(angle))) + + @classmethod + def from_radians(cls, angle: Union[int, float]) -> Heading: + deg = round(math.degrees(angle)) + return cls(Heading.reduce_angle(deg)) + + @classmethod + def random(cls, min_angle: int = 0, max_angle: int = 0) -> Heading: + return Heading.from_degrees(random.randint(min_angle, max_angle)) + + def __add__(self, other: Heading) -> Heading: + return Heading.from_degrees(self.degrees + other.degrees) + + def __sub__(self, other: Heading) -> Heading: + return Heading.from_degrees(self.degrees - other.degrees) + + @dataclass(frozen=True, order=True) class Pressure: pressure_in_inches_hg: float diff --git a/gen/aircraft.py b/gen/aircraft.py index 144344df..998696ac 100644 --- a/gen/aircraft.py +++ b/gen/aircraft.py @@ -81,7 +81,7 @@ from game.theater.missiontarget import MissionTarget from game.theater.theatergroundobject import TheaterGroundObject from game.transfers import MultiGroupTransport from game.unitmap import UnitMap -from game.utils import Distance, meters, nautical_miles, pairwise +from game.utils import Distance, Heading, meters, nautical_miles, pairwise from gen.ato import AirTaskingOrder, Package from gen.callsigns import create_group_callsign_from_unit from gen.flights.flight import ( diff --git a/gen/airsupportgen.py b/gen/airsupportgen.py index 409a0959..72f4fecc 100644 --- a/gen/airsupportgen.py +++ b/gen/airsupportgen.py @@ -17,6 +17,9 @@ from dcs.task import ( ) from dcs.unittype import UnitType +from game.utils import Heading +from .flights.ai_flight_planner_db import AEWC_CAPABLE +from .naming import namegen from .callsigns import callsign_for_support_unit from .conflictgen import Conflict from .flights.ai_flight_planner_db import AEWC_CAPABLE @@ -122,14 +125,14 @@ class AirSupportConflictGenerator: alt, airspeed = self._get_tanker_params(tanker_unit_type.dcs_unit_type) freq = self.radio_registry.alloc_uhf() tacan = self.tacan_registry.alloc_for_band(TacanBand.Y) - tanker_heading = ( + tanker_heading = Heading.from_degrees( self.conflict.red_cp.position.heading_between_point( self.conflict.blue_cp.position ) + TANKER_HEADING_OFFSET * i ) tanker_position = player_cp.position.point_from_heading( - tanker_heading, TANKER_DISTANCE + tanker_heading.degrees, TANKER_DISTANCE ) tanker_group = self.mission.refuel_flight( country=country, diff --git a/gen/armor.py b/gen/armor.py index 7e92169b..a000c8cb 100644 --- a/gen/armor.py +++ b/gen/armor.py @@ -32,7 +32,7 @@ from game.dcs.aircrafttype import AircraftType from game.dcs.groundunittype import GroundUnitType from game.theater.controlpoint import ControlPoint from game.unitmap import UnitMap -from game.utils import heading_sum, opposite_heading +from game.utils import Heading from gen.ground_forces.ai_ground_planner import ( DISTANCE_FROM_FRONTLINE, CombatGroup, @@ -130,7 +130,7 @@ class GroundConflictGenerator: self.player_stance, player_groups, enemy_groups, - self.conflict.heading + 90, + self.conflict.heading.right, self.conflict.blue_cp, self.conflict.red_cp, ) @@ -138,7 +138,7 @@ class GroundConflictGenerator: self.enemy_stance, enemy_groups, player_groups, - self.conflict.heading - 90, + self.conflict.heading.left, self.conflict.red_cp, self.conflict.blue_cp, ) @@ -182,7 +182,11 @@ class GroundConflictGenerator: ) def gen_infantry_group_for_group( - self, group: VehicleGroup, is_player: bool, side: Country, forward_heading: int + self, + group: VehicleGroup, + is_player: bool, + side: Country, + forward_heading: Heading, ) -> None: infantry_position = self.conflict.find_ground_position( @@ -217,7 +221,7 @@ class GroundConflictGenerator: u.dcs_unit_type, position=infantry_position, group_size=1, - heading=forward_heading, + heading=forward_heading.degrees, move_formation=PointAction.OffRoad, ) return @@ -244,7 +248,7 @@ class GroundConflictGenerator: units[0].dcs_unit_type, position=infantry_position, group_size=1, - heading=forward_heading, + heading=forward_heading.degrees, move_formation=PointAction.OffRoad, ) @@ -256,17 +260,19 @@ class GroundConflictGenerator: unit.dcs_unit_type, position=position, group_size=1, - heading=forward_heading, + heading=forward_heading.degrees, move_formation=PointAction.OffRoad, ) def _set_reform_waypoint( - self, dcs_group: VehicleGroup, forward_heading: int + self, dcs_group: VehicleGroup, forward_heading: Heading ) -> None: """Setting a waypoint close to the spawn position allows the group to reform gracefully rather than spin """ - reform_point = dcs_group.position.point_from_heading(forward_heading, 50) + reform_point = dcs_group.position.point_from_heading( + forward_heading.degrees, 50 + ) dcs_group.add_waypoint(reform_point) def _plan_artillery_action( @@ -274,7 +280,7 @@ class GroundConflictGenerator: stance: CombatStance, gen_group: CombatGroup, dcs_group: VehicleGroup, - forward_heading: int, + forward_heading: Heading, target: Point, ) -> bool: """ @@ -308,7 +314,7 @@ class GroundConflictGenerator: dcs_group, forward_heading, (int)(RETREAT_DISTANCE / 3) ) dcs_group.add_waypoint( - dcs_group.position.point_from_heading(forward_heading, 1), + dcs_group.position.point_from_heading(forward_heading.degrees, 1), PointAction.OffRoad, ) dcs_group.points[2].tasks.append(Hold()) @@ -336,7 +342,7 @@ class GroundConflictGenerator: self.mission.triggerrules.triggers.append(artillery_fallback) for u in dcs_group.units: - u.heading = forward_heading + random.randint(-5, 5) + u.heading = (forward_heading + Heading.random(-5, 5)).degrees return True return False @@ -345,7 +351,7 @@ class GroundConflictGenerator: stance: CombatStance, enemy_groups: List[Tuple[VehicleGroup, CombatGroup]], dcs_group: VehicleGroup, - forward_heading: int, + forward_heading: Heading, to_cp: ControlPoint, ) -> bool: """ @@ -378,9 +384,7 @@ class GroundConflictGenerator: else: # We use an offset heading here because DCS doesn't always # force vehicles to move if there's no heading change. - offset_heading = forward_heading - 2 - if offset_heading < 0: - offset_heading = 358 + offset_heading = forward_heading - Heading.from_degrees(2) attack_point = self.find_offensive_point( dcs_group, offset_heading, AGGRESIVE_MOVE_DISTANCE ) @@ -398,9 +402,7 @@ class GroundConflictGenerator: else: # We use an offset heading here because DCS doesn't always # force vehicles to move if there's no heading change. - offset_heading = forward_heading - 1 - if offset_heading < 0: - offset_heading = 359 + offset_heading = forward_heading - Heading.from_degrees(1) attack_point = self.find_offensive_point( dcs_group, offset_heading, BREAKTHROUGH_OFFENSIVE_DISTANCE ) @@ -436,7 +438,7 @@ class GroundConflictGenerator: self, stance: CombatStance, dcs_group: VehicleGroup, - forward_heading: int, + forward_heading: Heading, to_cp: ControlPoint, ) -> bool: """ @@ -473,7 +475,7 @@ class GroundConflictGenerator: stance: CombatStance, ally_groups: List[Tuple[VehicleGroup, CombatGroup]], enemy_groups: List[Tuple[VehicleGroup, CombatGroup]], - forward_heading: int, + forward_heading: Heading, from_cp: ControlPoint, to_cp: ControlPoint, ) -> None: @@ -514,12 +516,14 @@ class GroundConflictGenerator: else: retreat_point = self.find_retreat_point(dcs_group, forward_heading) reposition_point = retreat_point.point_from_heading( - forward_heading, 10 + forward_heading.degrees, 10 ) # Another point to make the unit face the enemy dcs_group.add_waypoint(retreat_point, PointAction.OffRoad) dcs_group.add_waypoint(reposition_point, PointAction.OffRoad) - def add_morale_trigger(self, dcs_group: VehicleGroup, forward_heading: int) -> None: + def add_morale_trigger( + self, dcs_group: VehicleGroup, forward_heading: Heading + ) -> None: """ This add a trigger to manage units fleeing whenever their group is hit hard, or being engaged by CAS """ @@ -532,7 +536,7 @@ class GroundConflictGenerator: # Force unit heading for unit in dcs_group.units: - unit.heading = forward_heading + unit.heading = forward_heading.degrees dcs_group.manualHeading = True # We add a new retreat waypoint @@ -563,7 +567,7 @@ class GroundConflictGenerator: def find_retreat_point( self, dcs_group: VehicleGroup, - frontline_heading: int, + frontline_heading: Heading, distance: int = RETREAT_DISTANCE, ) -> Point: """ @@ -573,14 +577,14 @@ class GroundConflictGenerator: :return: dcs.mapping.Point object with the desired position """ desired_point = dcs_group.points[0].position.point_from_heading( - heading_sum(frontline_heading, +180), distance + frontline_heading.opposite.degrees, distance ) if self.conflict.theater.is_on_land(desired_point): return desired_point return self.conflict.theater.nearest_land_pos(desired_point) def find_offensive_point( - self, dcs_group: VehicleGroup, frontline_heading: int, distance: int + self, dcs_group: VehicleGroup, frontline_heading: Heading, distance: int ) -> Point: """ Find a point to attack @@ -590,7 +594,7 @@ class GroundConflictGenerator: :return: dcs.mapping.Point object with the desired position """ desired_point = dcs_group.points[0].position.point_from_heading( - frontline_heading, distance + frontline_heading.degrees, distance ) if self.conflict.theater.is_on_land(desired_point): return desired_point @@ -688,14 +692,14 @@ class GroundConflictGenerator: conflict_position: Point, combat_width: int, distance_from_frontline: int, - heading: int, - spawn_heading: int, + heading: Heading, + spawn_heading: Heading, ) -> Optional[Point]: shifted = conflict_position.point_from_heading( - heading, random.randint(0, combat_width) + heading.degrees, random.randint(0, combat_width) ) desired_point = shifted.point_from_heading( - spawn_heading, distance_from_frontline + spawn_heading.degrees, distance_from_frontline ) return Conflict.find_ground_position( desired_point, combat_width, heading, self.conflict.theater @@ -704,17 +708,13 @@ class GroundConflictGenerator: def _generate_groups( self, groups: list[CombatGroup], - frontline_vector: Tuple[Point, int, int], + frontline_vector: Tuple[Point, Heading, int], is_player: bool, ) -> List[Tuple[VehicleGroup, CombatGroup]]: """Finds valid positions for planned groups and generates a pydcs group for them""" positioned_groups = [] position, heading, combat_width = frontline_vector - spawn_heading = ( - int(heading_sum(heading, -90)) - if is_player - else int(heading_sum(heading, 90)) - ) + spawn_heading = heading.left if is_player else heading.right country = self.game.coalition_for(is_player).country_name for group in groups: if group.role == CombatGroupRole.ARTILLERY: @@ -737,7 +737,7 @@ class GroundConflictGenerator: group.unit_type, group.size, final_position, - heading=opposite_heading(spawn_heading), + heading=spawn_heading.opposite, ) if is_player: g.set_skill(Skill(self.game.settings.player_skill)) @@ -750,7 +750,7 @@ class GroundConflictGenerator: g, is_player, self.mission.country(country), - opposite_heading(spawn_heading), + spawn_heading.opposite, ) else: logging.warning(f"Unable to get valid position for {group}") @@ -764,7 +764,7 @@ class GroundConflictGenerator: count: int, at: Point, move_formation: PointAction = PointAction.OffRoad, - heading: int = 0, + heading: Heading = Heading.from_degrees(0), ) -> VehicleGroup: if side == self.conflict.attackers_country: @@ -778,7 +778,7 @@ class GroundConflictGenerator: unit_type.dcs_unit_type, position=at, group_size=count, - heading=heading, + heading=heading.degrees, move_formation=move_formation, ) diff --git a/gen/coastal/silkworm.py b/gen/coastal/silkworm.py index 6712762a..b0fb98c5 100644 --- a/gen/coastal/silkworm.py +++ b/gen/coastal/silkworm.py @@ -3,6 +3,7 @@ from dcs.vehicles import MissilesSS, Unarmed, AirDefence from game import Game from game.factions.faction import Faction from game.theater.theatergroundobject import CoastalSiteGroundObject +from game.utils import Heading from gen.sam.group_generator import VehicleGroupGenerator @@ -59,5 +60,5 @@ class SilkwormGenerator(VehicleGroupGenerator[CoastalSiteGroundObject]): "STRELA#0", self.position.x + 200, self.position.y + 15, - 90, + Heading.from_degrees(90), ) diff --git a/gen/conflictgen.py b/gen/conflictgen.py index 5576805a..6693367e 100644 --- a/gen/conflictgen.py +++ b/gen/conflictgen.py @@ -9,7 +9,7 @@ from shapely.geometry import LineString, Point as ShapelyPoint from game.theater.conflicttheater import ConflictTheater, FrontLine from game.theater.controlpoint import ControlPoint -from game.utils import heading_sum, opposite_heading +from game.utils import Heading FRONTLINE_LENGTH = 80000 @@ -25,7 +25,7 @@ class Conflict: attackers_country: Country, defenders_country: Country, position: Point, - heading: Optional[int] = None, + heading: Optional[Heading] = None, size: Optional[int] = None, ): @@ -55,28 +55,28 @@ class Conflict: @classmethod def frontline_position( cls, frontline: FrontLine, theater: ConflictTheater - ) -> Tuple[Point, int]: - attack_heading = int(frontline.attack_heading) + ) -> Tuple[Point, Heading]: + attack_heading = frontline.attack_heading position = cls.find_ground_position( frontline.position, FRONTLINE_LENGTH, - heading_sum(attack_heading, 90), + attack_heading.right, theater, ) if position is None: raise RuntimeError("Could not find front line position") - return position, opposite_heading(attack_heading) + return position, attack_heading.opposite @classmethod def frontline_vector( cls, front_line: FrontLine, theater: ConflictTheater - ) -> Tuple[Point, int, int]: + ) -> Tuple[Point, Heading, int]: """ Returns a vector for a valid frontline location avoiding exclusion zones. """ center_position, heading = cls.frontline_position(front_line, theater) - left_heading = heading_sum(heading, -90) - right_heading = heading_sum(heading, 90) + left_heading = heading.left + right_heading = heading.right left_position = cls.extend_ground_position( center_position, int(FRONTLINE_LENGTH / 2), left_heading, theater ) @@ -113,10 +113,14 @@ class Conflict: @classmethod def extend_ground_position( - cls, initial: Point, max_distance: int, heading: int, theater: ConflictTheater + cls, + initial: Point, + max_distance: int, + heading: Heading, + theater: ConflictTheater, ) -> Point: """Finds the first intersection with an exclusion zone in one heading from an initial point up to max_distance""" - extended = initial.point_from_heading(heading, max_distance) + extended = initial.point_from_heading(heading.degrees, max_distance) if theater.landmap is None: # TODO: Why is this possible? return extended @@ -133,14 +137,14 @@ class Conflict: return extended # Otherwise extend the front line only up to the intersection. - return initial.point_from_heading(heading, p0.distance(intersection)) + return initial.point_from_heading(heading.degrees, p0.distance(intersection)) @classmethod def find_ground_position( cls, initial: Point, max_distance: int, - heading: int, + heading: Heading, theater: ConflictTheater, coerce: bool = True, ) -> Optional[Point]: @@ -153,10 +157,10 @@ class Conflict: if theater.is_on_land(pos): return pos for distance in range(0, int(max_distance), 100): - pos = initial.point_from_heading(heading, distance) + pos = initial.point_from_heading(heading.degrees, distance) if theater.is_on_land(pos): return pos - pos = initial.point_from_heading(opposite_heading(heading), distance) + pos = initial.point_from_heading(heading.opposite.degrees, distance) if theater.is_on_land(pos): return pos if coerce: diff --git a/gen/fleet/carrier_group.py b/gen/fleet/carrier_group.py index b25902a9..74ca4c67 100644 --- a/gen/fleet/carrier_group.py +++ b/gen/fleet/carrier_group.py @@ -1,6 +1,7 @@ import random from gen.sam.group_generator import ShipGroupGenerator +from game.utils import Heading from dcs.ships import USS_Arleigh_Burke_IIa, TICONDEROG @@ -54,7 +55,7 @@ class CarrierGroupGenerator(ShipGroupGenerator): ) # Add Ticonderoga escort - if self.heading >= 180: + if self.heading >= Heading.from_degrees(180): self.add_unit( TICONDEROG, "USS Hué City", diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py index fde43ee9..d3559442 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -37,8 +37,10 @@ from game.theater.theatergroundobject import ( NavalGroundObject, BuildingGroundObject, ) + from game.threatzones import ThreatZones -from game.utils import Distance, Speed, feet, meters, nautical_miles, knots +from game.utils import Distance, Heading, Speed, feet, meters, nautical_miles, knots + from .closestairfields import ObjectiveDistanceCache from .flight import Flight, FlightType, FlightWaypoint, FlightWaypointType from .traveltime import GroundSpeed, TravelTime @@ -1151,10 +1153,11 @@ class FlightPlanBuilder: """ assert self.package.waypoints is not None target = self.package.target.position - - heading = self.package.waypoints.join.heading_between_point(target) + heading = Heading.from_degrees( + self.package.waypoints.join.heading_between_point(target) + ) start_pos = target.point_from_heading( - heading, -self.doctrine.sweep_distance.meters + heading.degrees, -self.doctrine.sweep_distance.meters ) builder = WaypointBuilder(flight, self.coalition) @@ -1249,7 +1252,9 @@ class FlightPlanBuilder: else: raise PlanningError("Could not find any enemy airfields") - heading = location.position.heading_between_point(closest_airfield.position) + heading = Heading.from_degrees( + location.position.heading_between_point(closest_airfield.position) + ) position = ShapelyPoint( self.package.target.position.x, self.package.target.position.y @@ -1285,20 +1290,20 @@ class FlightPlanBuilder: ) end = location.position.point_from_heading( - heading, + heading.degrees, random.randint(int(min_cap_distance.meters), int(max_cap_distance.meters)), ) diameter = random.randint( int(self.doctrine.cap_min_track_length.meters), int(self.doctrine.cap_max_track_length.meters), ) - start = end.point_from_heading(heading - 180, diameter) + start = end.point_from_heading(heading.opposite.degrees, diameter) return start, end def aewc_orbit(self, location: MissionTarget) -> Point: closest_boundary = self.threat_zones.closest_boundary(location.position) - heading_to_threat_boundary = location.position.heading_between_point( - closest_boundary + heading_to_threat_boundary = Heading.from_degrees( + location.position.heading_between_point(closest_boundary) ) distance_to_threat = meters( location.position.distance_to_point(closest_boundary) @@ -1312,7 +1317,7 @@ class FlightPlanBuilder: orbit_distance = distance_to_threat - threat_buffer return location.position.point_from_heading( - orbit_heading, orbit_distance.meters + orbit_heading.degrees, orbit_distance.meters ) def racetrack_for_frontline( @@ -1320,9 +1325,9 @@ class FlightPlanBuilder: ) -> Tuple[Point, Point]: # Find targets waypoints ingress, heading, distance = Conflict.frontline_vector(front_line, self.theater) - center = ingress.point_from_heading(heading, distance / 2) + center = ingress.point_from_heading(heading.degrees, distance / 2) orbit_center = center.point_from_heading( - heading - 90, + heading.left.degrees, random.randint( int(nautical_miles(6).meters), int(nautical_miles(15).meters) ), @@ -1335,8 +1340,8 @@ class FlightPlanBuilder: combat_width = 35000 radius = combat_width * 1.25 - start = orbit_center.point_from_heading(heading, radius) - end = orbit_center.point_from_heading(heading + 180, radius) + start = orbit_center.point_from_heading(heading.degrees, radius) + end = orbit_center.point_from_heading(heading.opposite.degrees, radius) if end.distance_to_point(origin) < start.distance_to_point(origin): start, end = end, start @@ -1530,8 +1535,8 @@ class FlightPlanBuilder: raise InvalidObjectiveLocation(flight.flight_type, location) ingress, heading, distance = Conflict.frontline_vector(location, self.theater) - center = ingress.point_from_heading(heading, distance / 2) - egress = ingress.point_from_heading(heading, distance) + center = ingress.point_from_heading(heading.degrees, distance / 2) + egress = ingress.point_from_heading(heading.degrees, distance) ingress_distance = ingress.distance_to_point(flight.departure.position) egress_distance = egress.distance_to_point(flight.departure.position) @@ -1566,8 +1571,8 @@ class FlightPlanBuilder: location = self.package.target closest_boundary = self.threat_zones.closest_boundary(location.position) - heading_to_threat_boundary = location.position.heading_between_point( - closest_boundary + heading_to_threat_boundary = Heading.from_degrees( + location.position.heading_between_point(closest_boundary) ) distance_to_threat = meters( location.position.distance_to_point(closest_boundary) @@ -1582,16 +1587,16 @@ class FlightPlanBuilder: orbit_distance = distance_to_threat - threat_buffer racetrack_center = location.position.point_from_heading( - orbit_heading, orbit_distance.meters + orbit_heading.degrees, orbit_distance.meters ) racetrack_half_distance = Distance.from_nautical_miles(20).meters racetrack_start = racetrack_center.point_from_heading( - orbit_heading + 90, racetrack_half_distance + orbit_heading.right.degrees, racetrack_half_distance ) racetrack_end = racetrack_center.point_from_heading( - orbit_heading - 90, racetrack_half_distance + orbit_heading.left.degrees, racetrack_half_distance ) builder = WaypointBuilder(flight, self.coalition) diff --git a/gen/groundobjectsgen.py b/gen/groundobjectsgen.py index c7b7ca53..69d76998 100644 --- a/gen/groundobjectsgen.py +++ b/gen/groundobjectsgen.py @@ -55,7 +55,7 @@ from game.theater.theatergroundobject import ( SceneryGroundObject, ) from game.unitmap import UnitMap -from game.utils import feet, knots, mps +from game.utils import Heading, feet, knots, mps from .radios import RadioFrequency, RadioRegistry from .runways import RunwayData from .tacan import TacanBand, TacanChannel, TacanRegistry @@ -166,7 +166,7 @@ class MissileSiteGenerator(GenericGroundObjectGenerator[MissileSiteGroundObject] if targets: target = random.choice(targets) real_target = target.point_from_heading( - random.randint(0, 360), random.randint(0, 2500) + Heading.random().degrees, random.randint(0, 2500) ) vg.points[0].add_task(FireAtPoint(real_target)) logging.info("Set up fire task for missile group.") @@ -246,7 +246,7 @@ class BuildingSiteGenerator(GenericGroundObjectGenerator[BuildingGroundObject]): name=self.ground_object.group_name, _type=unit_type, position=self.ground_object.position, - heading=self.ground_object.heading, + heading=self.ground_object.heading.degrees, ) self._register_fortification(group) @@ -256,7 +256,7 @@ class BuildingSiteGenerator(GenericGroundObjectGenerator[BuildingGroundObject]): name=self.ground_object.group_name, _type=static_type, position=self.ground_object.position, - heading=self.ground_object.heading, + heading=self.ground_object.heading.degrees, dead=self.ground_object.is_dead, ) self._register_building(group) @@ -387,7 +387,9 @@ class GenericCarrierGenerator(GenericGroundObjectGenerator[GenericCarrierGroundO # time as the recovery window. brc = self.steam_into_wind(ship_group) self.activate_beacons(ship_group, tacan, tacan_callsign, icls) - self.add_runway_data(brc or 0, atc, tacan, tacan_callsign, icls) + self.add_runway_data( + brc or Heading.from_degrees(0), atc, tacan, tacan_callsign, icls + ) self._register_unit_group(group, ship_group) def get_carrier_type(self, group: ShipGroup) -> Type[ShipType]: @@ -422,14 +424,14 @@ class GenericCarrierGenerator(GenericGroundObjectGenerator[GenericCarrierGroundO ship.set_frequency(atc_channel.hertz) return ship - def steam_into_wind(self, group: ShipGroup) -> Optional[int]: - wind = self.game.conditions.weather.wind.at_0m - brc = wind.direction + 180 + def steam_into_wind(self, group: ShipGroup) -> Optional[Heading]: + wind = self.game.conditions.weather.wind.at_0m.direction + brc = Heading.from_degrees(wind.direction).opposite # Aim for 25kts over the deck. carrier_speed = knots(25) - mps(wind.speed) for attempt in range(5): point = group.points[0].position.point_from_heading( - brc, 100000 - attempt * 20000 + brc.degrees, 100000 - attempt * 20000 ) if self.game.theater.is_in_sea(point): group.points[0].speed = carrier_speed.meters_per_second @@ -459,7 +461,7 @@ class GenericCarrierGenerator(GenericGroundObjectGenerator[GenericCarrierGroundO def add_runway_data( self, - brc: int, + brc: Heading, atc: RadioFrequency, tacan: TacanChannel, callsign: str, @@ -593,7 +595,7 @@ class HelipadGenerator: logging.info("Generating helipad : " + name) pad = SingleHeliPad(name=(name + "_unit")) pad.position = Point(helipad.x, helipad.y) - pad.heading = helipad.heading + pad.heading = helipad.heading.degrees # pad.heliport_frequency = self.radio_registry.alloc_uhf() TODO : alloc radio & callsign sg = unitgroup.StaticGroup(self.m.next_group_id(), name) sg.add_unit(pad) diff --git a/gen/missiles/scud_site.py b/gen/missiles/scud_site.py index ca7f9b94..c57b43e3 100644 --- a/gen/missiles/scud_site.py +++ b/gen/missiles/scud_site.py @@ -5,6 +5,7 @@ from dcs.vehicles import Unarmed, MissilesSS, AirDefence from game import Game from game.factions.faction import Faction from game.theater.theatergroundobject import MissileSiteGroundObject +from game.utils import Heading from gen.sam.group_generator import VehicleGroupGenerator @@ -63,5 +64,5 @@ class ScudGenerator(VehicleGroupGenerator[MissileSiteGroundObject]): "STRELA#0", self.position.x + 200, self.position.y + 15, - 90, + Heading.from_degrees(90), ) diff --git a/gen/missiles/v1_group.py b/gen/missiles/v1_group.py index 9d377754..e42a94fe 100644 --- a/gen/missiles/v1_group.py +++ b/gen/missiles/v1_group.py @@ -5,6 +5,7 @@ from dcs.vehicles import Unarmed, MissilesSS, AirDefence from game import Game from game.factions.faction import Faction from game.theater.theatergroundobject import MissileSiteGroundObject +from game.utils import Heading from gen.sam.group_generator import VehicleGroupGenerator @@ -65,5 +66,5 @@ class V1GroupGenerator(VehicleGroupGenerator[MissileSiteGroundObject]): "Blitz#0", self.position.x + 200, self.position.y + 15, - 90, + Heading.from_degrees(90), ) diff --git a/gen/runways.py b/gen/runways.py index dfb0cebe..ef9ab52f 100644 --- a/gen/runways.py +++ b/gen/runways.py @@ -8,6 +8,7 @@ from typing import Iterator, Optional from dcs.terrain.terrain import Airport from game.weather import Conditions +from game.utils import Heading from .airfields import AIRFIELD_DATA from .radios import RadioFrequency from .tacan import TacanChannel @@ -16,7 +17,7 @@ from .tacan import TacanChannel @dataclass(frozen=True) class RunwayData: airfield_name: str - runway_heading: int + runway_heading: Heading runway_name: str atc: Optional[RadioFrequency] = None tacan: Optional[TacanChannel] = None @@ -26,7 +27,7 @@ class RunwayData: @classmethod def for_airfield( - cls, airport: Airport, runway_heading: int, runway_name: str + cls, airport: Airport, runway_heading: Heading, runway_name: str ) -> RunwayData: """Creates RunwayData for the given runway of an airfield. @@ -66,12 +67,14 @@ class RunwayData: runway_number = runway.heading // 10 runway_side = ["", "L", "R"][runway.leftright] runway_name = f"{runway_number:02}{runway_side}" - yield cls.for_airfield(airport, runway.heading, runway_name) + yield cls.for_airfield( + airport, Heading.from_degrees(runway.heading), runway_name + ) # pydcs only exposes one runway per physical runway, so to expose # both sides of the runway we need to generate the other. - heading = (runway.heading + 180) % 360 - runway_number = heading // 10 + heading = Heading.from_degrees(runway.heading).opposite + runway_number = heading.degrees // 10 runway_side = ["", "R", "L"][runway.leftright] runway_name = f"{runway_number:02}{runway_side}" yield cls.for_airfield(airport, heading, runway_name) @@ -81,10 +84,10 @@ class RunwayAssigner: def __init__(self, conditions: Conditions): self.conditions = conditions - def angle_off_headwind(self, runway: RunwayData) -> int: - wind = self.conditions.weather.wind.at_0m.direction - ideal_heading = (wind + 180) % 360 - return abs(runway.runway_heading - ideal_heading) + def angle_off_headwind(self, runway: RunwayData) -> Heading: + wind = Heading.from_degrees(self.conditions.weather.wind.at_0m.direction) + ideal_heading = wind.opposite + return runway.runway_heading.angle_between(ideal_heading) def get_preferred_runway(self, airport: Airport) -> RunwayData: """Returns the preferred runway for the given airport. diff --git a/gen/sam/aaa_flak.py b/gen/sam/aaa_flak.py index 68dee391..0e27a8d2 100644 --- a/gen/sam/aaa_flak.py +++ b/gen/sam/aaa_flak.py @@ -6,6 +6,7 @@ from gen.sam.airdefensegroupgenerator import ( AirDefenseRange, AirDefenseGroupGenerator, ) +from game.utils import Heading GFLAK = [ AirDefence.Flak38, @@ -88,7 +89,7 @@ class FlakGenerator(AirDefenseGroupGenerator): "BLITZ#" + str(index), self.position.x + 125 + 15 * i + random.randint(1, 5), self.position.y + 15 * j + random.randint(1, 5), - 75, + Heading.from_degrees(75), ) @classmethod diff --git a/gen/sam/aaa_ww2_ally_flak.py b/gen/sam/aaa_ww2_ally_flak.py index 5fc18ddc..4eed42f4 100644 --- a/gen/sam/aaa_ww2_ally_flak.py +++ b/gen/sam/aaa_ww2_ally_flak.py @@ -6,6 +6,7 @@ from gen.sam.airdefensegroupgenerator import ( AirDefenseRange, AirDefenseGroupGenerator, ) +from game.utils import Heading class AllyWW2FlakGenerator(AirDefenseGroupGenerator): @@ -53,28 +54,28 @@ class AllyWW2FlakGenerator(AirDefenseGroupGenerator): "CMD#1", self.position.x, self.position.y - 20, - random.randint(0, 360), + Heading.random(), ) self.add_unit( Unarmed.M30_CC, "LOG#1", self.position.x, self.position.y + 20, - random.randint(0, 360), + Heading.random(), ) self.add_unit( Unarmed.M4_Tractor, "LOG#2", self.position.x + 20, self.position.y, - random.randint(0, 360), + Heading.random(), ) self.add_unit( Unarmed.Bedford_MWD, "LOG#3", self.position.x - 20, self.position.y, - random.randint(0, 360), + Heading.random(), ) @classmethod diff --git a/gen/sam/cold_war_flak.py b/gen/sam/cold_war_flak.py index 788482ec..bb538434 100644 --- a/gen/sam/cold_war_flak.py +++ b/gen/sam/cold_war_flak.py @@ -41,7 +41,7 @@ class EarlyColdWarFlakGenerator(AirDefenseGroupGenerator): "SHO#1", self.position.x - 40, self.position.y - 40, - self.heading + 180, + self.heading.opposite, ), self.add_unit( AirDefence.S_60_Type59_Artillery, @@ -57,7 +57,7 @@ class EarlyColdWarFlakGenerator(AirDefenseGroupGenerator): "SHO#3", self.position.x - 80, self.position.y - 40, - self.heading + 180, + self.heading.opposite, ), self.add_unit( AirDefence.ZU_23_Emplacement_Closed, @@ -113,7 +113,7 @@ class ColdWarFlakGenerator(AirDefenseGroupGenerator): "SHO#1", self.position.x - 40, self.position.y - 40, - self.heading + 180, + self.heading.opposite, ), self.add_unit( AirDefence.S_60_Type59_Artillery, @@ -129,7 +129,7 @@ class ColdWarFlakGenerator(AirDefenseGroupGenerator): "SHO#3", self.position.x - 80, self.position.y - 40, - self.heading + 180, + self.heading.opposite, ), self.add_unit( AirDefence.ZU_23_Emplacement_Closed, diff --git a/gen/sam/freya_ewr.py b/gen/sam/freya_ewr.py index 7c61a25c..e484d53e 100644 --- a/gen/sam/freya_ewr.py +++ b/gen/sam/freya_ewr.py @@ -4,6 +4,7 @@ from gen.sam.airdefensegroupgenerator import ( AirDefenseRange, AirDefenseGroupGenerator, ) +from game.utils import Heading class FreyaGenerator(AirDefenseGroupGenerator): @@ -101,7 +102,7 @@ class FreyaGenerator(AirDefenseGroupGenerator): "Inf#3", self.position.x + 20, self.position.y - 24, - self.heading + 45, + self.heading + Heading.from_degrees(45), ) @classmethod diff --git a/gen/sam/group_generator.py b/gen/sam/group_generator.py index 2fb800f8..e8137e19 100644 --- a/gen/sam/group_generator.py +++ b/gen/sam/group_generator.py @@ -16,6 +16,7 @@ from dcs.unittype import VehicleType, UnitType, ShipType from game.dcs.groundunittype import GroundUnitType from game.factions.faction import Faction from game.theater.theatergroundobject import TheaterGroundObject, NavalGroundObject +from game.utils import Heading if TYPE_CHECKING: from game.game import Game @@ -37,7 +38,7 @@ class GroupGenerator(Generic[GroupT, UnitT, UnitTypeT, TgoT]): self.game = game self.go = ground_object self.position = ground_object.position - self.heading = random.randint(0, 359) + self.heading: Heading = Heading.random() self.price = 0 self.vg: GroupT = group @@ -53,7 +54,7 @@ class GroupGenerator(Generic[GroupT, UnitT, UnitTypeT, TgoT]): name: str, pos_x: float, pos_y: float, - heading: int, + heading: Heading, ) -> UnitT: return self.add_unit_to_group( self.vg, unit_type, name, Point(pos_x, pos_y), heading @@ -65,7 +66,7 @@ class GroupGenerator(Generic[GroupT, UnitT, UnitTypeT, TgoT]): unit_type: UnitTypeT, name: str, position: Point, - heading: int, + heading: Heading, ) -> UnitT: raise NotImplementedError @@ -91,11 +92,11 @@ class VehicleGroupGenerator( unit_type: Type[VehicleType], name: str, position: Point, - heading: int, + heading: Heading, ) -> Vehicle: unit = Vehicle(self.game.next_unit_id(), f"{group.name}|{name}", unit_type.id) unit.position = position - unit.heading = heading + unit.heading = heading.degrees group.add_unit(unit) # get price of unit to calculate the real price of the whole group @@ -109,7 +110,7 @@ class VehicleGroupGenerator( def get_circular_position( self, num_units: int, launcher_distance: int, coverage: int = 90 - ) -> Iterable[tuple[float, float, int]]: + ) -> Iterable[tuple[float, float, Heading]]: """ Given a position on the map, array a group of units in a circle a uniform distance from the unit :param num_units: @@ -131,9 +132,9 @@ class VehicleGroupGenerator( positions = [] if num_units % 2 == 0: - current_offset = self.heading - ((coverage / (num_units - 1)) / 2) + current_offset = self.heading.degrees - ((coverage / (num_units - 1)) / 2) else: - current_offset = self.heading + current_offset = self.heading.degrees current_offset -= outer_offset * (math.ceil(num_units / 2) - 1) for _ in range(1, num_units + 1): x: float = self.position.x + launcher_distance * math.cos( @@ -142,8 +143,7 @@ class VehicleGroupGenerator( y: float = self.position.y + launcher_distance * math.sin( math.radians(current_offset) ) - heading = current_offset - positions.append((x, y, int(heading))) + positions.append((x, y, Heading.from_degrees(current_offset))) current_offset += outer_offset return positions @@ -172,10 +172,10 @@ class ShipGroupGenerator( unit_type: Type[ShipType], name: str, position: Point, - heading: int, + heading: Heading, ) -> Ship: unit = Ship(self.game.next_unit_id(), f"{self.go.group_name}|{name}", unit_type) unit.position = position - unit.heading = heading + unit.heading = heading.degrees group.add_unit(unit) return unit diff --git a/gen/visualgen.py b/gen/visualgen.py index 83be4859..3a11652e 100644 --- a/gen/visualgen.py +++ b/gen/visualgen.py @@ -86,7 +86,7 @@ class VisualGenerator: continue for offset in range(0, distance, self.game.settings.perf_smoke_spacing): - position = plane_start.point_from_heading(heading, offset) + position = plane_start.point_from_heading(heading.degrees, offset) for k, v in FRONT_SMOKE_TYPE_CHANCES.items(): if random.randint(0, 100) <= k: diff --git a/qt_ui/widgets/map/mapmodel.py b/qt_ui/widgets/map/mapmodel.py index fce7a5d9..24024bc1 100644 --- a/qt_ui/widgets/map/mapmodel.py +++ b/qt_ui/widgets/map/mapmodel.py @@ -417,12 +417,12 @@ class FrontLineJs(QObject): def extents(self) -> List[LeafletLatLon]: a = self.theater.point_to_ll( self.front_line.position.point_from_heading( - self.front_line.attack_heading + 90, nautical_miles(2).meters + self.front_line.attack_heading.right.degrees, nautical_miles(2).meters ) ) b = self.theater.point_to_ll( self.front_line.position.point_from_heading( - self.front_line.attack_heading + 270, nautical_miles(2).meters + self.front_line.attack_heading.left.degrees, nautical_miles(2).meters ) ) return [[a.latitude, a.longitude], [b.latitude, b.longitude]] From edbd3de4a4f8d62adeb98880f97e012d2bab75a8 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Wed, 21 Jul 2021 17:10:29 -0700 Subject: [PATCH 36/42] Bump campaign version to 8.0 for latest DCS. Building IDs changed again. Ack the change in my two campaigns which don't use these target types. --- game/version.py | 6 +++++- resources/campaigns/battle_of_abu_dhabi.json | 2 +- resources/campaigns/black_sea.json | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/game/version.py b/game/version.py index 7c989e2a..87d8a841 100644 --- a/game/version.py +++ b/game/version.py @@ -106,4 +106,8 @@ VERSION = _build_version_string() #: #: Version 7.1 #: * Support for Mariana Islands terrain -CAMPAIGN_FORMAT_VERSION = (7, 1) +#: +#: Version 8.0 +#: * DCS 2.7.4.9632 changed scenery target IDs. Any mission using map buildings as +#: strike targets must check and potentially recreate all those objectives. +CAMPAIGN_FORMAT_VERSION = (8, 0) diff --git a/resources/campaigns/battle_of_abu_dhabi.json b/resources/campaigns/battle_of_abu_dhabi.json index 8bbd80fb..5d6c25ca 100644 --- a/resources/campaigns/battle_of_abu_dhabi.json +++ b/resources/campaigns/battle_of_abu_dhabi.json @@ -7,5 +7,5 @@ "description": "

You have managed to establish a foothold near Ras Al Khaima. Continue pushing south.

", "miz": "battle_of_abu_dhabi.miz", "performance": 2, - "version": "7.0" + "version": "8.0" } \ No newline at end of file diff --git a/resources/campaigns/black_sea.json b/resources/campaigns/black_sea.json index 02f4ddbe..94cc5e02 100644 --- a/resources/campaigns/black_sea.json +++ b/resources/campaigns/black_sea.json @@ -5,5 +5,5 @@ "description": "

A medium sized theater with bases along the coast of the Black Sea.

", "miz": "black_sea.miz", "performance": 2, - "version": "7.0" + "version": "8.0" } \ No newline at end of file From 109408587259a8e453ccb202400dc0bb6c7e850a Mon Sep 17 00:00:00 2001 From: bgreman <47828384+bgreman@users.noreply.github.com> Date: Thu, 22 Jul 2021 15:30:46 -0400 Subject: [PATCH 37/42] Fixes #1449 and updates another area where the Heading class can apply (#1451) --- game/weather.py | 10 +++++----- gen/groundobjectsgen.py | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/game/weather.py b/game/weather.py index 952335bd..2594ed91 100644 --- a/game/weather.py +++ b/game/weather.py @@ -12,7 +12,7 @@ from dcs.weather import CloudPreset, Weather as PydcsWeather, Wind from game.savecompat import has_save_compat_for from game.settings import Settings -from game.utils import Distance, meters, interpolate, Pressure, inches_hg +from game.utils import Distance, Heading, meters, interpolate, Pressure, inches_hg if TYPE_CHECKING: from game.theater import ConflictTheater @@ -149,7 +149,7 @@ class Weather: @staticmethod def random_wind(minimum: int, maximum: int) -> WindConditions: - wind_direction = random.randint(0, 360) + wind_direction = Heading.random() at_0m_factor = 1 at_2000m_factor = 2 at_8000m_factor = 3 @@ -157,9 +157,9 @@ class Weather: return WindConditions( # Always some wind to make the smoke move a bit. - at_0m=Wind(wind_direction, max(1, base_wind * at_0m_factor)), - at_2000m=Wind(wind_direction, base_wind * at_2000m_factor), - at_8000m=Wind(wind_direction, base_wind * at_8000m_factor), + at_0m=Wind(wind_direction.degrees, max(1, base_wind * at_0m_factor)), + at_2000m=Wind(wind_direction.degrees, base_wind * at_2000m_factor), + at_8000m=Wind(wind_direction.degrees, base_wind * at_8000m_factor), ) @staticmethod diff --git a/gen/groundobjectsgen.py b/gen/groundobjectsgen.py index 69d76998..4efcfb92 100644 --- a/gen/groundobjectsgen.py +++ b/gen/groundobjectsgen.py @@ -425,7 +425,7 @@ class GenericCarrierGenerator(GenericGroundObjectGenerator[GenericCarrierGroundO return ship def steam_into_wind(self, group: ShipGroup) -> Optional[Heading]: - wind = self.game.conditions.weather.wind.at_0m.direction + wind = self.game.conditions.weather.wind.at_0m brc = Heading.from_degrees(wind.direction).opposite # Aim for 25kts over the deck. carrier_speed = knots(25) - mps(wind.speed) From dd50ee92a9e55e9de644701b0e9dafd5a66a082f Mon Sep 17 00:00:00 2001 From: RndName Date: Tue, 20 Jul 2021 11:44:27 +0200 Subject: [PATCH 38/42] calculate heading to center of conflict for sams --- changelog.md | 1 + gen/sam/airdefensegroupgenerator.py | 1 + gen/sam/group_generator.py | 23 +++++++++++++++++++++++ 3 files changed, 25 insertions(+) diff --git a/changelog.md b/changelog.md index d8d0cc36..9eb637f4 100644 --- a/changelog.md +++ b/changelog.md @@ -27,6 +27,7 @@ Saves from 4.0.0 are compatible with 4.1.0. * **[Campaign]** Air defense sites now generate a fixed number of launchers per type. * **[Campaign]** Added support for Mariana Islands map. * **[Mission Generation]** Improvements for better support of the Skynet Plugin and long range SAMs are now acting as EWR +* **[Mission Generation]** SAM sites are now headed towards the center of the conflict * **[Plugins]** Increased time JTAC Autolase messages stay visible on the UI. * **[UI]** Added ability to take notes and have those notes appear as a kneeboard page. * **[UI]** Hovering over the weather information now dispalys the cloud base (meters and feet). diff --git a/gen/sam/airdefensegroupgenerator.py b/gen/sam/airdefensegroupgenerator.py index 36755036..f755cafa 100644 --- a/gen/sam/airdefensegroupgenerator.py +++ b/gen/sam/airdefensegroupgenerator.py @@ -48,6 +48,7 @@ class AirDefenseGroupGenerator(VehicleGroupGenerator[SamGroundObject], ABC): self.vg.name = self.group_name_for_role(self.vg.id, self.primary_group_role()) self.auxiliary_groups: List[VehicleGroup] = [] + self.heading = self.heading_to_conflict() def add_auxiliary_group(self, role: SkynetRole) -> VehicleGroup: gid = self.game.next_group_id() diff --git a/gen/sam/group_generator.py b/gen/sam/group_generator.py index e8137e19..d7266186 100644 --- a/gen/sam/group_generator.py +++ b/gen/sam/group_generator.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging import math +import operator import random from collections import Iterable from typing import TYPE_CHECKING, Type, TypeVar, Generic, Any @@ -15,6 +16,7 @@ from dcs.unittype import VehicleType, UnitType, ShipType from game.dcs.groundunittype import GroundUnitType from game.factions.faction import Faction +from game.theater import MissionTarget from game.theater.theatergroundobject import TheaterGroundObject, NavalGroundObject from game.utils import Heading @@ -70,6 +72,27 @@ class GroupGenerator(Generic[GroupT, UnitT, UnitTypeT, TgoT]): ) -> UnitT: raise NotImplementedError + def heading_to_conflict(self) -> int: + # Heading for a Group to the enemy. + # Should be the point between the nearest and the most distant conflict + conflicts: dict[MissionTarget, float] = {} + + for conflict in self.game.theater.conflicts(): + conflicts[conflict] = conflict.distance_to(self.go) + + if len(conflicts) == 0: + return self.heading + + closest_conflict = min(conflicts.items(), key=operator.itemgetter(1))[0] + most_distant_conflict = max(conflicts.items(), key=operator.itemgetter(1))[0] + + conflict_center = Point( + (closest_conflict.position.x + most_distant_conflict.position.x) / 2, + (closest_conflict.position.y + most_distant_conflict.position.y) / 2, + ) + + return int(self.go.position.heading_between_point(conflict_center)) + class VehicleGroupGenerator( Generic[TgoT], GroupGenerator[VehicleGroup, Vehicle, Type[VehicleType], TgoT] From 458de17b8fb1e5f940a74f4e11a531512d3149d7 Mon Sep 17 00:00:00 2001 From: RndName Date: Wed, 21 Jul 2021 17:27:06 +0200 Subject: [PATCH 39/42] adopt sam heading to new heading class --- gen/sam/group_generator.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/gen/sam/group_generator.py b/gen/sam/group_generator.py index d7266186..bbe6bdb9 100644 --- a/gen/sam/group_generator.py +++ b/gen/sam/group_generator.py @@ -72,7 +72,7 @@ class GroupGenerator(Generic[GroupT, UnitT, UnitTypeT, TgoT]): ) -> UnitT: raise NotImplementedError - def heading_to_conflict(self) -> int: + def heading_to_conflict(self) -> Heading: # Heading for a Group to the enemy. # Should be the point between the nearest and the most distant conflict conflicts: dict[MissionTarget, float] = {} @@ -91,7 +91,9 @@ class GroupGenerator(Generic[GroupT, UnitT, UnitTypeT, TgoT]): (closest_conflict.position.y + most_distant_conflict.position.y) / 2, ) - return int(self.go.position.heading_between_point(conflict_center)) + return Heading.from_degrees( + self.go.position.heading_between_point(conflict_center) + ) class VehicleGroupGenerator( From 9f23cb35a95a71fd25887010d4cf5f3e1618da92 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Fri, 23 Jul 2021 22:48:01 -0700 Subject: [PATCH 40/42] Update pydcs to latest master. --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 54073289..5cdc508f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,7 +19,7 @@ pathspec==0.8.1 pefile==2019.4.18 Pillow==8.2.0 pre-commit==2.10.1 --e git://github.com/pydcs/dcs@2baba37e32bc55fed59ef977c43dad275c9821eb#egg=pydcs +-e git://github.com/pydcs/dcs@7eb720b341c95ad4c3659cc934be4029d526c36e#egg=pydcs pyinstaller==4.3 pyinstaller-hooks-contrib==2021.1 pyparsing==2.4.7 From 80bf3c97b22e4912943b0d28083a30427f43264d Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Sat, 24 Jul 2021 15:10:22 -0700 Subject: [PATCH 41/42] Remove the SA-10 from Syria 2011. They didn't get this until a few years later. This was a stand-in for the SA-5 that DCS doesn't have, but the SA-10 is so much more capable that it's not a good replacement. --- resources/factions/syria_2011.json | 1 - 1 file changed, 1 deletion(-) diff --git a/resources/factions/syria_2011.json b/resources/factions/syria_2011.json index 07e77af1..31c8e78b 100644 --- a/resources/factions/syria_2011.json +++ b/resources/factions/syria_2011.json @@ -61,7 +61,6 @@ "SA8Generator", "SA8Generator", "SA9Generator", - "SA10Generator", "SA11Generator", "SA13Generator", "SA17Generator", From 67fa4a891077af74bc9fe99d27c662c0c32da83a Mon Sep 17 00:00:00 2001 From: RndName Date: Sun, 25 Jul 2021 14:59:56 +0200 Subject: [PATCH 42/42] fix generation of empty transfer during cp capture when a cp capture happens and the next cp has pending unit deliveries then they will be redeployed to the newly captured cp. The redeploy was drecreasing the num of pending unit deliveries for the old cp but was not removing them completly from the dict when all were removed --- changelog.md | 1 + game/unitdelivery.py | 5 ++++- qt_ui/windows/basemenu/QRecruitBehaviour.py | 2 -- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/changelog.md b/changelog.md index 9eb637f4..73dbc29f 100644 --- a/changelog.md +++ b/changelog.md @@ -43,6 +43,7 @@ Saves from 4.0.0 are compatible with 4.1.0. * **[Mission Generation]** The lua data for other plugins is now generated correctly * **[Mission Generation]** Fixed problem with opfor planning missions against sold ground objects like SAMs * **[Mission Generation]** The legacy always-available tanker option no longer prevents mission creation. +* **[Mission Generation]** Prevent the creation of a transfer order with 0 units for a rare situtation when a point was captured. * **[Mission Generation]** Fix occasional KeyError preventing mission generation when all units of the same type in a convoy were killed. * **[UI]** Statistics window tick marks are now always integers. * **[UI]** Statistics window now shows the correct info for the turn diff --git a/game/unitdelivery.py b/game/unitdelivery.py index 7dbfb0a0..cf1af512 100644 --- a/game/unitdelivery.py +++ b/game/unitdelivery.py @@ -40,7 +40,10 @@ class PendingUnitDeliveries: def sell(self, units: dict[UnitType[Any], int]) -> None: for k, v in units.items(): - self.units[k] -= v + if self.units[k] > v: + self.units[k] -= v + else: + del self.units[k] def refund_all(self, coalition: Coalition) -> None: self.refund(coalition, self.units) diff --git a/qt_ui/windows/basemenu/QRecruitBehaviour.py b/qt_ui/windows/basemenu/QRecruitBehaviour.py index b3ab3d8f..77b0258b 100644 --- a/qt_ui/windows/basemenu/QRecruitBehaviour.py +++ b/qt_ui/windows/basemenu/QRecruitBehaviour.py @@ -209,8 +209,6 @@ class QRecruitBehaviour: if self.pending_deliveries.available_next_turn(unit_type) > 0: self.budget += unit_type.price self.pending_deliveries.sell({unit_type: 1}) - if self.pending_deliveries.units[unit_type] == 0: - del self.pending_deliveries.units[unit_type] self.update_purchase_controls() self.update_available_budget() return True