diff --git a/changelog.md b/changelog.md index 9be996fd..73dbc29f 100644 --- a/changelog.md +++ b/changelog.md @@ -1,15 +1,23 @@ # 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 * **[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. +* **[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. ## 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. @@ -19,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). @@ -34,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/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/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/commander/aircraftallocator.py b/game/commander/aircraftallocator.py index 16ea678a..a50dbd22 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,13 +61,18 @@ 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( 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/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 4f944833..7ef7c59a 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 @@ -47,8 +38,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 @@ -83,16 +79,33 @@ 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 "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) +class MissionPlannerMaxRanges: + @has_save_compat_for(5) + def __init__(self) -> None: + pass + + MODERN_DOCTRINE = Doctrine( cap=True, cas=True, @@ -100,10 +113,11 @@ 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), - 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), @@ -136,10 +150,11 @@ 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), - 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), @@ -171,11 +186,12 @@ 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), - 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/data/weapons.py b/game/data/weapons.py index 5d0b0dd1..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) @@ -181,6 +206,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) diff --git a/game/dcs/aircrafttype.py b/game/dcs/aircrafttype.py index dd9b5282..5158f240 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 @@ -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]]): @@ -112,13 +141,20 @@ 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 + + fuel_consumption: Optional[FuelConsumption] + intra_flight_radio: Optional[Radio] channel_allocator: Optional[RadioChannelAllocator] channel_namer: Type[ChannelNamer] @@ -230,6 +266,25 @@ 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) + ) + logging.warning( + f"{aircraft.id} does not specify a max_range. Defaulting to " + 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: @@ -257,6 +312,8 @@ class AircraftType(UnitType[Type[FlyingType]]): max_group_size=data.get("max_group_size", aircraft.group_size_max), patrol_altitude=patrol_config.altitude, patrol_speed=patrol_config.speed, + 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/flightplan/__init__.py b/game/flightplan/__init__.py new file mode 100644 index 00000000..17a92708 --- /dev/null +++ b/game/flightplan/__init__.py @@ -0,0 +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 new file mode 100644 index 00000000..a909cf03 --- /dev/null +++ b/game/flightplan/ipzonegeometry.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import shapely.ops +from dcs import Point +from shapely.geometry import Point as ShapelyPoint, MultiPolygon + +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 + + 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: + # 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_zones, self.home)[0] + + def find_best_ip(self) -> Point: + if self.safe_zones.is_empty: + ip = self._unsafe_ip() + else: + ip = self._safe_ip() + return Point(ip.x, ip.y) diff --git a/game/flightplan/joinzonegeometry.py b/game/flightplan/joinzonegeometry.py new file mode 100644 index 00000000..02e00fa4 --- /dev/null +++ b/game/flightplan/joinzonegeometry.py @@ -0,0 +1,103 @@ +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, + MultiLineString, +) + +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 + ) -> 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) + + 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 + # 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), + ] + ) + + 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 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: + 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/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/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/procurement.py b/game/procurement.py index 8820453c..d1c254f0 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,28 @@ 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 ( + squadron.operates_from(airbase) + and request.task_capability + in squadron.auto_assignable_mission_types + ): break else: continue @@ -241,13 +243,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 +288,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/squadrons.py b/game/squadrons.py index 3a23d4ea..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 @@ -101,9 +131,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 +208,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 @@ -250,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] @@ -288,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, @@ -377,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, @@ -414,6 +456,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/conflicttheater.py b/game/theater/conflicttheater.py index e0f4d69a..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 @@ -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 @@ -398,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) + 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: @@ -497,6 +527,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 @@ -633,10 +674,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 @@ -719,6 +764,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 +797,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 +829,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 +861,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 +893,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 +925,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 +957,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 +986,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/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 0bf85391..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 @@ -123,7 +124,6 @@ class GameGenerator: GroundObjectGenerator(game, self.generator_settings).generate() game.settings.version = VERSION - game.begin_turn_0() return game def prepare_theater(self) -> None: @@ -386,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"], ) @@ -586,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/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/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/game/utils.py b/game/utils.py index 2370c56f..119a741a 100644 --- a/game/utils.py +++ b/game/utils.py @@ -2,9 +2,10 @@ from __future__ import annotations import itertools import math +import random 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 @@ -16,14 +17,8 @@ KPH_TO_KNOTS = 1 / KNOTS_TO_KPH MS_TO_KPH = 3.6 KPH_TO_MS = 1 / MS_TO_KPH - -def heading_sum(h: int, a: int) -> int: - h += a - return h % 360 - - -def opposite_heading(h: int) -> int: - return heading_sum(h, 180) +INHG_TO_HPA = 33.86389 +INHG_TO_MMHG = 25.400002776728 @dataclass(frozen=True, order=True) @@ -181,7 +176,85 @@ def mach(value: float, altitude: Distance) -> Speed: SPEED_OF_SOUND_AT_SEA_LEVEL = knots(661.5) -def pairwise(iterable: Iterable[Any]) -> Iterable[tuple[Any, Any]]: +@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 + + @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_TO_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) + + +PairwiseT = TypeVar("PairwiseT") + + +def pairwise(iterable: Iterable[PairwiseT]) -> Iterable[tuple[PairwiseT, PairwiseT]]: """ itertools recipe s -> (s0,s1), (s1,s2), (s2, s3), ... @@ -189,3 +262,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/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/game/weather.py b/game/weather.py index fae1d5a0..2594ed91 100644 --- a/game/weather.py +++ b/game/weather.py @@ -5,16 +5,18 @@ 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 +from game.utils import Distance, Heading, meters, interpolate, Pressure, inches_hg if TYPE_CHECKING: from game.theater import ConflictTheater + from game.theater.conflicttheater import SeasonalConditions class TimeOfDay(Enum): @@ -26,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: @@ -71,15 +81,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=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]: @@ -98,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 @@ -106,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 @@ -120,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.2) - return max(SAFE_MIN, min(SAFE_MAX, pressure)) + pressure = random.normalvariate(average_pressure, 0.1) + return inches_hg(max(SAFE_MIN, min(SAFE_MAX, pressure))) @staticmethod def random_temperature(average_temperature: float) -> float: @@ -136,17 +187,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 +222,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 +242,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 +262,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 +302,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 +328,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 +344,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) diff --git a/gen/aircraft.py b/gen/aircraft.py index 392cd70c..998696ac 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, 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 ( @@ -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/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/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/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/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 0927b968..d3559442 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -20,6 +20,8 @@ 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, ControlPoint, @@ -35,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 @@ -137,6 +141,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 @@ -946,87 +961,33 @@ 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 + package_airfield = self.package_airfield() - 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: - # The entire path to the target is threatened. Use the fallback behavior for - # now. - self.legacy_package_waypoints_impl() - return + # Start by picking the best IP for the attack. + ingress_point = IpZoneGeometry( + self.package.target.position, + package_airfield.position, + self.coalition, + ).find_best_ip() - 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. - self.package.waypoints = PackageWaypoints( - WaypointBuilder.perturb(join_point), + join_point = JoinZoneGeometry( + self.package.target.position, + package_airfield.position, ingress_point, - WaypointBuilder.perturb( - self.preferred_split_point(ingress_point, join_point) - ), - ) + self.coalition, + ).find_best_join_point() - def retreat_point(self, origin: Point) -> Point: - return self.threat_zones.closest_boundary(origin) - - def legacy_package_waypoints_impl(self) -> None: - from gen.ato import PackageWaypoints - - ingress_point = self._ingress_point(self._target_heading_to_package_airfield()) - join_point = self._rendezvous_point(ingress_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. + # 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(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_points(self) -> Iterator[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( - ingress_point, self.package_airfield().position - ): - return point - return join_point - def generate_strike(self, flight: Flight) -> StrikeFlightPlan: """Generates a strike flight plan. @@ -1192,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) @@ -1290,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 @@ -1326,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) @@ -1353,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( @@ -1361,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) ), @@ -1376,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 @@ -1571,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) @@ -1607,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) @@ -1623,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) @@ -1702,48 +1666,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( @@ -1806,59 +1732,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.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/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/gen/groundobjectsgen.py b/gen/groundobjectsgen.py index c7b7ca53..4efcfb92 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]: + def steam_into_wind(self, group: ShipGroup) -> Optional[Heading]: wind = self.game.conditions.weather.wind.at_0m - brc = wind.direction + 180 + 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/kneeboard.py b/gen/kneeboard.py index 35aac4e3..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,15 +40,14 @@ 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 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 @@ -112,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) @@ -200,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 @@ -217,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), ] ) @@ -255,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 @@ -277,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) @@ -303,18 +321,24 @@ 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()}") - 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" ) @@ -578,20 +602,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 +683,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) 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/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/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..bbe6bdb9 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,7 +16,9 @@ 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 if TYPE_CHECKING: from game.game import Game @@ -37,7 +40,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 +56,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,10 +68,33 @@ class GroupGenerator(Generic[GroupT, UnitT, UnitTypeT, TgoT]): unit_type: UnitTypeT, name: str, position: Point, - heading: int, + heading: Heading, ) -> UnitT: raise NotImplementedError + 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] = {} + + 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 Heading.from_degrees( + self.go.position.heading_between_point(conflict_center) + ) + class VehicleGroupGenerator( Generic[TgoT], GroupGenerator[VehicleGroup, Vehicle, Type[VehicleType], TgoT] @@ -91,11 +117,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 +135,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 +157,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 +168,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 +197,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/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 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/main.py b/qt_ui/main.py index 26c5cb48..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 @@ -183,8 +184,24 @@ 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.") + lint_weapons = subparsers.add_parser("lint-weapons") + lint_weapons.add_argument("aircraft", help="Name of the aircraft variant to lint.") + return parser.parse_args() @@ -196,6 +213,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 +243,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, @@ -246,14 +266,26 @@ def create_game( high_digit_sams=False, ), ) - return generator.generate() + game = generator.generate() + game.begin_turn_0() + 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) @@ -265,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"): @@ -277,7 +309,12 @@ def main(): args.auto_procurement, args.inverted, args.cheats, + 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) diff --git a/qt_ui/widgets/map/mapmodel.py b/qt_ui/widgets/map/mapmodel.py index 02eadd9f..24024bc1 100644 --- a/qt_ui/widgets/map/mapmodel.py +++ b/qt_ui/widgets/map/mapmodel.py @@ -8,10 +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, HoldZoneGeometry from game.navmesh import NavMesh, NavMeshPoly from game.profiling import logged_duration from game.theater import ( @@ -27,7 +34,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 +51,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 @@ -73,6 +89,18 @@ 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] + + +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() @@ -389,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]] @@ -512,6 +540,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 +810,209 @@ class UnculledZone(QObject): ) +class IpZonesJs(QObject): + homeBubbleChanged = Signal() + ipBubbleChanged = Signal() + permissibleZoneChanged = Signal() + safeZonesChanged = Signal() + + def __init__( + self, + 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_zones = safe_zones + + @Property(list, notify=homeBubbleChanged) + def homeBubble(self) -> LeafletPoly: + return self._home_bubble + + @Property(list, notify=ipBubbleChanged) + def ipBubble(self) -> LeafletPoly: + return self._ip_bubble + + @Property(list, notify=permissibleZoneChanged) + def permissibleZone(self) -> LeafletPoly: + return self._permissible_zone + + @Property(list, notify=safeZonesChanged) + def safeZones(self) -> list[LeafletPoly]: + return self._safe_zones + + @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 + geometry = IpZoneGeometry(target.position, home.position, game.blue) + return IpZonesJs( + 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), + ) + + +class JoinZonesJs(QObject): + homeBubbleChanged = Signal() + targetBubbleChanged = Signal() + ipBubbleChanged = Signal() + excludedZonesChanged = Signal() + permissibleZonesChanged = Signal() + preferredLinesChanged = Signal() + + def __init__( + self, + home_bubble: LeafletPoly, + target_bubble: LeafletPoly, + ip_bubble: LeafletPoly, + excluded_zones: list[LeafletPoly], + 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_zones = permissible_zones + self._preferred_lines = preferred_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=ipBubbleChanged) + def ipBubble(self) -> LeafletPoly: + return self._ip_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=preferredLinesChanged) + def preferredLines(self) -> list[list[LeafletLatLon]]: + return self._preferred_lines + + @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) + 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_to_leaflet_polys(geometry.permissible_zones, game.theater), + shapely_lines_to_leaflet_points(geometry.preferred_lines, game.theater), + ) + + +class HoldZonesJs(QObject): + homeBubbleChanged = Signal() + targetBubbleChanged = Signal() + joinBubbleChanged = Signal() + excludedZonesChanged = Signal() + permissibleZonesChanged = Signal() + preferredLinesChanged = Signal() + + def __init__( + self, + home_bubble: LeafletPoly, + target_bubble: LeafletPoly, + join_bubble: LeafletPoly, + excluded_zones: list[LeafletPoly], + permissible_zones: list[LeafletPoly], + preferred_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._preferred_lines = preferred_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=preferredLinesChanged) + def preferredLines(self) -> list[list[LeafletLatLon]]: + return self._preferred_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_lines_to_leaflet_points(geometry.preferred_lines, game.theater), + ) + + class MapModel(QObject): cleared = Signal() @@ -782,6 +1026,9 @@ class MapModel(QObject): navmeshesChanged = Signal() mapZonesChanged = Signal() unculledZonesChanged = Signal() + ipZonesChanged = Signal() + joinZonesChanged = Signal() + holdZonesChanged = Signal() def __init__(self, game_model: GameModel) -> None: super().__init__() @@ -798,6 +1045,9 @@ class MapModel(QObject): self._navmeshes = NavMeshJs([], []) self._map_zones = MapZonesJs([], [], []) 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) @@ -821,6 +1071,7 @@ class MapModel(QObject): self._navmeshes = NavMeshJs([], []) self._map_zones = MapZonesJs([], [], []) self._unculled_zones = [] + self._ip_zones = IpZonesJs.empty() self.cleared.emit() def set_package_selection(self, index: int) -> None: @@ -896,11 +1147,30 @@ 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 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]: @@ -1029,6 +1299,18 @@ 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(JoinZonesJs, notify=joinZonesChanged) + 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/qt_ui/windows/AirWingConfigurationDialog.py b/qt_ui/windows/AirWingConfigurationDialog.py new file mode 100644 index 00000000..85539d06 --- /dev/null +++ b/qt_ui/windows/AirWingConfigurationDialog.py @@ -0,0 +1,326 @@ +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, + QLabel, + QWidget, + QScrollArea, + QLineEdit, + QTextEdit, + QCheckBox, + QHBoxLayout, + QStackedLayout, + QTabWidget, +) + +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 + + +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) + + 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.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() + + 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 SquadronConfigurationLayout(QVBoxLayout): + def __init__(self, squadrons: list[Squadron]) -> None: + super().__init__() + self.squadron_configs = [] + for squadron in squadrons: + squadron_config = SquadronConfigurationBox(squadron) + self.squadron_configs.append(squadron_config) + self.addWidget(squadron_config) + + def apply(self) -> list[Squadron]: + keep_squadrons = [] + for squadron_config in self.squadron_configs: + if squadron_config.isChecked(): + keep_squadrons.append(squadron_config.apply()) + return keep_squadrons + + +class AircraftSquadronsPage(QWidget): + def __init__(self, squadrons: list[Squadron]) -> None: + super().__init__() + layout = QVBoxLayout() + self.setLayout(layout) + + self.squadrons_config = SquadronConfigurationLayout(squadrons) + + scrolling_widget = QWidget() + scrolling_widget.setLayout(self.squadrons_config) + + scrolling_area = QScrollArea() + scrolling_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + scrolling_area.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn) + scrolling_area.setWidgetResizable(True) + scrolling_area.setWidget(scrolling_widget) + + 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 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.""" + + 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) + + tab_widget = QTabWidget() + layout.addWidget(tab_widget) + + 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: + for tab in self.tabs: + tab.apply() + super().reject() 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 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() 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 diff --git a/resources/campaigns/battle_of_abu_dhabi.json b/resources/campaigns/battle_of_abu_dhabi.json index 9cfa5476..5d6c25ca 100644 --- a/resources/campaigns/battle_of_abu_dhabi.json +++ b/resources/campaigns/battle_of_abu_dhabi.json @@ -4,8 +4,8 @@ "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" + "version": "8.0" } \ No newline at end of file diff --git a/resources/campaigns/battle_of_abu_dhabi.miz b/resources/campaigns/battle_of_abu_dhabi.miz index a3c11d5e..dcc30acf 100644 Binary files a/resources/campaigns/battle_of_abu_dhabi.miz and b/resources/campaigns/battle_of_abu_dhabi.miz differ 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 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", diff --git a/resources/ui/map/map.js b/resources/ui/map/map.js index 6cae9ae2..4e4ff59e 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 noWaypointZones = L.layerGroup(); +const ipZones = L.layerGroup(); +const joinZones = L.layerGroup(); +const holdZones = L.layerGroup().addTo(map); + +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["Waypoint Zones"] = { + None: noWaypointZones, + "IP Zones": ipZones, + "Join Zones": joinZones, + "Hold Zones": holdZones, + }; +} + // 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,16 @@ 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", + "Waypoint Zones", + ], + groupCheckboxes: true, + }) .addTo(map); let game; @@ -291,6 +312,9 @@ new QWebChannel(qt.webChannelTransport, function (channel) { game.navmeshesChanged.connect(drawNavmeshes); game.mapZonesChanged.connect(drawMapZones); game.unculledZonesChanged.connect(drawUnculledZones); + game.ipZonesChanged.connect(drawIpZones); + game.joinZonesChanged.connect(drawJoinZones); + game.holdZonesChanged.connect(drawHoldZones); }); function recenterMap(center) { @@ -570,7 +594,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 +998,138 @@ function drawUnculledZones() { } } +function drawIpZones() { + ipZones.clearLayers(); + + if (!ENABLE_EXPENSIVE_DEBUG_TOOLS) { + return; + } + + L.polygon(game.ipZones.homeBubble, { + color: Colors.Highlight, + fillOpacity: 0.1, + interactive: false, + }).addTo(ipZones); + + L.polygon(game.ipZones.ipBubble, { + color: "#bb89ff", + fillOpacity: 0.1, + interactive: false, + }).addTo(ipZones); + + L.polygon(game.ipZones.permissibleZone, { + color: "#ffffff", + 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() { + joinZones.clearLayers(); + + if (!ENABLE_EXPENSIVE_DEBUG_TOOLS) { + return; + } + + 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); + + for (const zone of game.joinZones.excludedZones) { + L.polygon(zone, { + color: "#ffa500", + fillOpacity: 0.2, + stroke: false, + interactive: false, + }).addTo(joinZones); + } + + 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, + }).addTo(joinZones); + } +} + +function drawHoldZones() { + holdZones.clearLayers(); + + if (!ENABLE_EXPENSIVE_DEBUG_TOOLS) { + return; + } + + L.polygon(game.holdZones.homeBubble, { + color: Colors.Highlight, + fillOpacity: 0.1, + interactive: false, + }).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); + } + + for (const line of game.holdZones.preferredLines) { + L.polyline(line, { + color: Colors.Green, + interactive: false, + }).addTo(holdZones); + } +} + function drawInitialMap() { recenterMap(game.mapCenter); drawControlPoints(); @@ -981,6 +1141,9 @@ function drawInitialMap() { drawNavmeshes(); drawMapZones(); drawUnculledZones(); + drawIpZones(); + drawJoinZones(); + drawHoldZones(); } function clearAllLayers() { 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/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: {} 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 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}" 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}" 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}" diff --git a/resources/weapons/bombs/GBU-10-2X.yaml b/resources/weapons/bombs/GBU-10-2X.yaml new file mode 100644 index 00000000..c0c51345 --- /dev/null +++ b/resources/weapons/bombs/GBU-10-2X.yaml @@ -0,0 +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 new file mode 100644 index 00000000..4b7306d1 --- /dev/null +++ b/resources/weapons/bombs/GBU-10.yaml @@ -0,0 +1,8 @@ +name: GBU-10 +type: LGB +year: 1976 +fallback: Mk 84 +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..2cd83f89 --- /dev/null +++ b/resources/weapons/bombs/GBU-12-2X.yaml @@ -0,0 +1,15 @@ +name: 2xGBU-12 +type: LGB +year: 1976 +fallback: 2xMk 82 +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..85705c79 --- /dev/null +++ b/resources/weapons/bombs/GBU-12.yaml @@ -0,0 +1,8 @@ +name: GBU-12 +type: LGB +year: 1976 +fallback: Mk 82 +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..19afd987 --- /dev/null +++ b/resources/weapons/bombs/GBU-16-2X.yaml @@ -0,0 +1,8 @@ +name: 2xGBU-16 +type: LGB +year: 1976 +fallback: 2xMk 83 +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..c966af60 --- /dev/null +++ b/resources/weapons/bombs/GBU-16.yaml @@ -0,0 +1,8 @@ +name: GBU-16 +type: LGB +year: 1976 +fallback: Mk 83 +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..6258584f --- /dev/null +++ b/resources/weapons/bombs/GBU-24.yaml @@ -0,0 +1,8 @@ +name: GBU-24 +type: LGB +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}" 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 64ef6833..a33ee9ca 100644 --- a/resources/weapons/pods/atflir.yaml +++ b/resources/weapons/pods/atflir.yaml @@ -1,4 +1,8 @@ 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. +fallback: AIM-120C clsids: - "{AN_ASQ_228}" 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 0ea9db08..4bee3ed6 100644 --- a/resources/weapons/pods/litening.yaml +++ b/resources/weapons/pods/litening.yaml @@ -1,5 +1,15 @@ 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 +# 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}" 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}"